From 030e0d201682f05bf7f097d23e5f459ded484754 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:35:32 +0100 Subject: [PATCH 01/31] Used SVG for map image generation, added support for not connected traces and added room rendering with labels. --- deebot_client/map.py | 373 ++++++++++++++++++++++--------------------- 1 file changed, 193 insertions(+), 180 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 04d315d9..de7d4169 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -11,10 +11,12 @@ import struct from typing import Any, Final import zlib +import re +import itertools from numpy import float64, reshape, zeros from numpy.typing import NDArray -from PIL import Image, ImageDraw, ImageOps +from PIL import Image, ImageDraw from deebot_client.events.map import MapChangedEvent @@ -40,10 +42,19 @@ _LOGGER = get_logger(__name__) _PIXEL_WIDTH = 50 -_POSITION_PNG = { - PositionType.DEEBOT: "iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAIAAABvrngfAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF0WlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMC0wNS0yNFQxMjowMzoxNiswMjowMCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo0YWM4NWY5MC1hNWMwLTE2NDktYTQ0MC0xMWM0NWY5OGQ1MDYiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDo3Zjk3MTZjMi1kZDM1LWJiNDItYjMzZS1hYjYwY2Y4ZTZlZDYiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpiMzhiNGZlMS1lOGNkLTJjNDctYmQwZC1lNmZiNzRhMjFkMDciIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpiMzhiNGZlMS1lOGNkLTJjNDctYmQwZC1lNmZiNzRhMjFkMDciIHN0RXZ0OndoZW49IjIwMjAtMDUtMjRUMTI6MDM6MTYrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE5IChXaW5kb3dzKSIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6NGFjODVmOTAtYTVjMC0xNjQ5LWE0NDAtMTFjNDVmOThkNTA2IiBzdEV2dDp3aGVuPSIyMDIwLTA1LTI0VDEyOjAzOjE2KzAyOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+AP7+NwAAAFpJREFUCJllzEEKgzAQhtFvMkSsEKj30oUXrYserELA1obhd+nCd4BnksZ53X4Cnr193ov59Iq+o2SA2vz4p/iKkgkRouTYlbhJ/jBqww03avPBTNI4rdtx9ScfWyYCg52e0gAAAABJRU5ErkJggg==", # nopep8 - PositionType.CHARGER: "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAOCAYAAAAWo42rAAAAdUlEQVQoU2NkQAP/nzD8BwkxyjAwIkuhcEASRCmEKYKZhGwq3ER0ReiKSVOIyzRkU8EmwhUyKzAwSNyHyL9QZGD4+wDMBLmVEasimFHIiuEKpcHBhwmeQryBMJFohcjuw2s1SBKHZ8BWo/gauyshvobJEYoZAEOSPXnhzwZnAAAAAElFTkSuQmCC", # nopep8 + +_POSITIONS_SVG_ORDER = { + PositionType.DEEBOT: 0, + PositionType.CHARGER: 1, } + +_SVG_COORDS_COMPACT = re.compile(r"(?:(?<=\D)\s)|(?:\s(?=\D))") + +_SVG_MAP_MARGIN = 5 + +# Categorigal palette for 12 non related elements +_ROOM_COLORS = ["#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99", "#e31a1c", "#fdbf6f", "#ff7f00", "#cab2d6", "#6a3d9a", "#ffff99", "#b15928"] + _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { @@ -77,10 +88,11 @@ def _decompress_7z_base64_data(data: str) -> bytes: return decompressed_data -def _calc_value(value: int, min_value: int, max_value: int) -> int: +def _calc_value(value: int, min_value: int, max_value: int) -> float: try: if value is not None: - new_value = int((int(value) / _PIXEL_WIDTH) + _OFFSET) + # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. + new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET # return value inside min and max return min(max_value, max(min_value, new_value)) @@ -92,7 +104,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> int: def _calc_point( x: int, y: int, image_box: tuple[int, int, int, int] | None -) -> tuple[int, int]: +) -> tuple[float, float]: if image_box is None: image_box = (0, 0, x, y) @@ -101,44 +113,62 @@ def _calc_point( _calc_value(y, image_box[1], image_box[3]), ) - -def _draw_positions( - positions: list[Position], - image: Image.Image, - image_box: tuple[int, int, int, int] | None, +def _points_to_svg_path( + points: list[Any] ) -> None: - for position in positions: - icon = Image.open(BytesIO(base64.b64decode(_POSITION_PNG[position.type]))) - image.paste( - icon, - _calc_point(position.x, position.y, image_box), - icon.convert("RGBA"), - ) + # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted + # SVG path instruction. + path_points = [] + for prev_p, p in itertools.pairwise([None, *points]): + if p != prev_p: # Skip repeated points + if (prev_p): + # Relativize coords in order to generate compacted path + path_points.append("l" if len(p) == 2 or p[2] else "m") + path_points.extend(map(lambda a, b: str(a - b), p[0 : 2], prev_p[0 : 2])) + else: + # No previous point, use absolute coordinates for initial position + path_points.append("M") + path_points.extend(map(str, p[0 : 2])) + # Further compact the path (keep only whitespaces between two numeric characters) + return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) -def _draw_subset( + + +def _get_svg_positions( + positions: list[Position], + image_box: tuple[int, int, int, int] | None, +) -> str: + svg_positions = [] + for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): + pos = _calc_point(position.x, position.y, image_box) + svg_positions.append(f"") + + return "".join(svg_positions) + +def _get_svg_subset( subset: MapSubsetEvent, - draw: "DashedImageDraw", image_box: tuple[int, int, int, int] | None, -) -> None: +) -> str: coordinates_ = ast.literal_eval(subset.coordinates) + points: list[tuple[int, int]] = [ _calc_point(coordinates_[i], coordinates_[i + 1], image_box) for i in range(0, len(coordinates_), 2) ] if len(points) == 4: - # close rectangle - points.append(points[0]) - - draw.dashed_line(points, dash=(3, 2), fill=_COLORS[subset.type], width=1) - + # Return polygon + svg_coords = list(sum(points, ())) + return f"""""" + # Return path + return f"""""" class Map: """Map representation.""" - RESIZE_FACTOR = 3 - def __init__( self, execute_command: Callable[[Command], Coroutine[Any, Any, None]], @@ -196,13 +226,13 @@ def _update_trace_points(self, data: str) -> None: for i in range(0, len(trace_points), 5): byte_position_x = struct.unpack("> 7) & 1) != 0) + type = point_data & 1 - self._map_data.trace_values.append(position_x) - self._map_data.trace_values.append(position_y) + self._map_data.trace_values.append((byte_position_x[0], byte_position_y[0], connected, type)) _LOGGER.debug("[_update_trace_points] finish") @@ -237,6 +267,57 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: if pixel_type in [0x01, 0x02, 0x03]: draw.point((point_x, point_y), fill=_COLORS[pixel_type]) + def _get_svg_traces_path(self) -> str: + if len(self._map_data.trace_values) > 0: + _LOGGER.debug("[get_svg_map] Draw Trace") + + return f"""""" + + return "" + + def _get_svg_rooms(self, image_box: tuple[int, int, int, int], image_box_center: tuple[float, float]) -> tuple[list[str], list[str]] : + svg_rooms_elements = [] + svg_rooms_labels = [] + + for room, color in zip(sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS)): + # Split coordinates into a flat sequence + room_coords = re.split("[;,]",_decompress_7z_base64_data(self._map_data.rooms[room].coordinates).decode('ascii')) + + # SVG compacted presentation + svg_room_coords = _SVG_COORDS_COMPACT.sub("", " ".join(room_coords)) + + # Append to room svg elements + svg_rooms_elements.append( + f"""""") + + + room_name = self._map_data.rooms[room].name + if room_name != "Default": + # Calculate label positions (cannot use SVG transformations, as they are applied to the whole text, + # which would result in text to be vertically flipped...) + + # Get a rough room center. + room_center_x = sum(float(x) for x in room_coords[0::2]) / (len(room_coords) / 2) + room_center_y = sum(float(y) for y in room_coords[1::2]) / (len(room_coords) / 2) + + # Get map relative position + room_center_pos = _calc_point(room_center_x, room_center_y, image_box) + + # Add the text, with position vertically flipped on map center + svg_rooms_labels.append(f"""{room_name}""") + + return (svg_rooms_elements, svg_rooms_labels) + def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -304,8 +385,8 @@ def refresh(self) -> None: self._event_bus.request_refresh(MapTraceEvent) self._event_bus.request_refresh(MajorMapEvent) - def get_base64_map(self, width: int | None = None) -> bytes: - """Return map as base64 image string.""" + def get_svg_map(self, width: int | None = None) -> str: + """Return map as SVG string.""" if not self._unsubscribers: raise MapError("Please enable the map first") @@ -314,74 +395,91 @@ def get_base64_map(self, width: int | None = None) -> bytes: and width == self._last_image.width and not self._map_data.changed ): - _LOGGER.debug("[get_base64_map] No need to update") - return self._last_image.base64_image - - _LOGGER.debug("[get_base64_map] Begin") + _LOGGER.debug("[get_svg_map] No need to update") + return self._last_image.svg_image + + _LOGGER.debug("[get_svg_map] Begin") + image = Image.new("RGBA", (6400, 6400)) - draw = DashedImageDraw(image) - + draw = ImageDraw.ImageDraw(image) self._draw_map_pieces(draw) - - # Draw Trace Route - if len(self._map_data.trace_values) > 0: - _LOGGER.debug("[get_base64_map] Draw Trace") - draw.line(self._map_data.trace_values, fill=_COLORS[_TRACE_MAP], width=1) - - image_box = image.getbbox() - for subset in self._map_data.map_subsets.values(): - _draw_subset(subset, draw, image_box) - del draw - _draw_positions(self._map_data.positions, image, image_box) - - _LOGGER.debug("[get_base64_map] Crop Image") - cropped = image.crop(image_box) - del image + image_box = image.getbbox() - _LOGGER.debug("[get_base64_map] Flipping Image") - cropped = ImageOps.flip(cropped) + if image_box: + image_box_center = ((image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2) - _LOGGER.debug( - "[get_base64_map] Map current Size: X: %d Y: %d", - cropped.size[0], - cropped.size[1], - ) + _LOGGER.debug("[get_svg_map] Crop Image") + cropped = image.crop(image_box) + del image - new_size = None - if width is not None and width > 0: - height = int((width / cropped.size[0]) * cropped.size[1]) _LOGGER.debug( - "[get_base64_map] Resize based on the requested width: %d and calculated height %d", - width, - height, + "[get_svg_map] Map current Size: X: %d Y: %d", + cropped.size[0], + cropped.size[1], ) - new_size = (width, height) - elif cropped.size[0] > 400 or cropped.size[1] > 400: - _LOGGER.debug("[get_base64_map] Resize disabled.. map over 400") - else: - resize_factor = Map.RESIZE_FACTOR - _LOGGER.debug("[get_base64_map] Resize factor: %d", resize_factor) - new_size = ( - cropped.size[0] * resize_factor, - cropped.size[1] * resize_factor, - ) - - if new_size is not None: - cropped = cropped.resize(new_size, Image.Resampling.NEAREST) - - _LOGGER.debug("[get_base64_map] Saving to buffer") - buffered = BytesIO() - cropped.save(buffered, format="PNG") - del cropped - base64_image = base64.b64encode(buffered.getvalue()) + _LOGGER.debug("[get_svg_map] Saving to buffer") + buffered = BytesIO() + cropped.save(buffered, format="PNG") + del cropped + + base64_bg = base64.b64encode(buffered.getvalue()) + + # Build the SVG XML + + svg_positions = _get_svg_positions(self._map_data.positions, image_box) + + svg_subset_elements = [_get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values()] + + svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms(image_box, image_box_center) + + svg_traces_path = self._get_svg_traces_path() + + svg_map = f""" + + + + + + + + + + + + + + + + + + + + + + + {"".join(svg_rooms_elements)} + {"".join(svg_subset_elements)} + {svg_traces_path} + {svg_positions} + + {"".join(svg_rooms_labels)} + + """ + else: + # No map data yet, generate an empty SVG. + svg_map = """""" + self._map_data.reset_changed() - self._last_image = LastImage(base64_image, width) - _LOGGER.debug("[get_base64_map] Finish") + self._last_image = LastImage(svg_map, width) + _LOGGER.debug("[get_svg_map] Finish") + + return svg_map - return base64_image async def teardown(self) -> None: """Teardown map.""" @@ -448,95 +546,10 @@ def __eq__(self, obj: object) -> bool: return self._crc32 == obj._crc32 and self._index == obj._index -class DashedImageDraw(ImageDraw.ImageDraw): - """Class extend ImageDraw by dashed line.""" - - # Copied from https://stackoverflow.com/a/65893631 Credits ands - _FILL = str | int | tuple[int, int, int] | tuple[int, int, int, int] | None - - def _thick_line( - self, - xy: list[tuple[int, int]], - direction: list[tuple[int, int]], - fill: _FILL = None, - width: int = 0, - ) -> None: - if xy[0] != xy[1]: - self.line(xy, fill=fill, width=width) - else: - x1, y1 = xy[0] - delta_x = direction[1][0] - direction[0][0] - delta_y = direction[1][1] - direction[0][1] - - if delta_x < 0: - y1 -= 1 - - if delta_y < 0: - x1 -= 1 - - if delta_y != 0: - if delta_x != 0: - k = -delta_x / delta_y - a = 1 / math.sqrt(1 + k**2) - b = (width * a - 1) / 2 - else: - k = 0 - b = (width - 1) / 2 - x1 = x1 - math.floor(b) - y1 = y1 - int(k * b) - x2 = x1 + math.ceil(b) - y2 = y1 + int(k * b) - else: - y1 = y1 - math.floor((width - 1) / 2) - x2 = x1 - y2 = y1 + math.ceil((width - 1) / 2) - self.line([(x1, y1), (x2, y2)], fill=fill, width=1) - - def dashed_line( - self, - xy: list[tuple[int, int]], - dash: tuple[int, int] = (2, 2), - fill: _FILL = None, - width: int = 0, - ) -> None: - """Draw a dashed line, or a connected sequence of line segments.""" - for i in range(len(xy) - 1): - x1, y1 = xy[i] - x2, y2 = xy[i + 1] - x_length = x2 - x1 - y_length = y2 - y1 - length = math.sqrt(x_length**2 + y_length**2) - dash_enabled = True - position = 0 - while position < length: - for dash_step in dash: - if dash_enabled: - start = position / length - end = min((position + dash_step - 1) / length, 1) - self._thick_line( - [ - ( - round(x1 + start * x_length), - round(y1 + start * y_length), - ), - ( - round(x1 + end * x_length), - round(y1 + end * y_length), - ), - ], - xy, - fill, - width, - ) - dash_enabled = not dash_enabled - position += dash_step - - @dataclasses.dataclass(frozen=True) class LastImage: """Last created image.""" - - base64_image: bytes + svg_image: str width: int | None @@ -557,7 +570,7 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[int] = OnChangedList(on_change) + self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList(on_change) @property def changed(self) -> bool: @@ -592,7 +605,7 @@ def rooms(self) -> dict[int, Room]: return self._rooms @property - def trace_values(self) -> OnChangedList[int]: + def trace_values(self) -> OnChangedList[tuple[int, int, bool, int]]: """Return trace values.""" return self._trace_values From 3d434932e63b77618eb0fbb7ab656f6f27ecfa5e Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:16:04 +0100 Subject: [PATCH 02/31] Code formatting --- deebot_client/map.py | 159 +++++++++++++++++++++++++++---------------- 1 file changed, 101 insertions(+), 58 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index de7d4169..fec2ead8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -6,13 +6,12 @@ import dataclasses from datetime import UTC, datetime from io import BytesIO +import itertools import lzma -import math +import re import struct from typing import Any, Final import zlib -import re -import itertools from numpy import float64, reshape, zeros from numpy.typing import NDArray @@ -53,7 +52,20 @@ _SVG_MAP_MARGIN = 5 # Categorigal palette for 12 non related elements -_ROOM_COLORS = ["#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99", "#e31a1c", "#fdbf6f", "#ff7f00", "#cab2d6", "#6a3d9a", "#ffff99", "#b15928"] +_ROOM_COLORS = [ + "#a6cee3", + "#1f78b4", + "#b2df8a", + "#33a02c", + "#fb9a99", + "#e31a1c", + "#fdbf6f", + "#ff7f00", + "#cab2d6", + "#6a3d9a", + "#ffff99", + "#b15928", +] _OFFSET = 400 _TRACE_MAP = "trace_map" @@ -92,7 +104,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> float: try: if value is not None: # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. - new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET + new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET # return value inside min and max return min(max_value, max(min_value, new_value)) @@ -113,28 +125,26 @@ def _calc_point( _calc_value(y, image_box[1], image_box[3]), ) -def _points_to_svg_path( - points: list[Any] -) -> None: - # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted + +def _points_to_svg_path(points: list[Any]) -> None: + # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted # SVG path instruction. path_points = [] for prev_p, p in itertools.pairwise([None, *points]): - if p != prev_p: # Skip repeated points - if (prev_p): + if p != prev_p: # Skip repeated points + if prev_p: # Relativize coords in order to generate compacted path path_points.append("l" if len(p) == 2 or p[2] else "m") - path_points.extend(map(lambda a, b: str(a - b), p[0 : 2], prev_p[0 : 2])) + path_points.extend(map(lambda a, b: str(a - b), p[0:2], prev_p[0:2])) else: # No previous point, use absolute coordinates for initial position path_points.append("M") - path_points.extend(map(str, p[0 : 2])) + path_points.extend(map(str, p[0:2])) # Further compact the path (keep only whitespaces between two numeric characters) return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) - def _get_svg_positions( positions: list[Position], image_box: tuple[int, int, int, int] | None, @@ -142,16 +152,19 @@ def _get_svg_positions( svg_positions = [] for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): pos = _calc_point(position.x, position.y, image_box) - svg_positions.append(f"") - + svg_positions.append( + f"" + ) + return "".join(svg_positions) + def _get_svg_subset( subset: MapSubsetEvent, image_box: tuple[int, int, int, int] | None, ) -> str: coordinates_ = ast.literal_eval(subset.coordinates) - + points: list[tuple[int, int]] = [ _calc_point(coordinates_[i], coordinates_[i + 1], image_box) for i in range(0, len(coordinates_), 2) @@ -160,12 +173,13 @@ def _get_svg_subset( if len(points) == 4: # Return polygon svg_coords = list(sum(points, ())) - return f"""""" # Return path - return f"""""" + class Map: """Map representation.""" @@ -226,13 +240,15 @@ def _update_trace_points(self, data: str) -> None: for i in range(0, len(trace_points), 5): byte_position_x = struct.unpack("> 7) & 1) != 0) + connected = point_data >> 7 & 1 == 0 type = point_data & 1 - self._map_data.trace_values.append((byte_position_x[0], byte_position_y[0], connected, type)) + self._map_data.trace_values.append( + (byte_position_x[0], byte_position_y[0], connected, type) + ) _LOGGER.debug("[_update_trace_points] finish") @@ -270,54 +286,71 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: def _get_svg_traces_path(self) -> str: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - - return f"""""" - + return "" - def _get_svg_rooms(self, image_box: tuple[int, int, int, int], image_box_center: tuple[float, float]) -> tuple[list[str], list[str]] : + def _get_svg_rooms( + self, + image_box: tuple[int, int, int, int], + image_box_center: tuple[float, float], + ) -> tuple[list[str], list[str]]: svg_rooms_elements = [] svg_rooms_labels = [] - for room, color in zip(sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS)): + for room, color in zip( + sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS) + ): # Split coordinates into a flat sequence - room_coords = re.split("[;,]",_decompress_7z_base64_data(self._map_data.rooms[room].coordinates).decode('ascii')) + room_coords = re.split( + "[;,]", + _decompress_7z_base64_data( + self._map_data.rooms[room].coordinates + ).decode("ascii"), + ) # SVG compacted presentation svg_room_coords = _SVG_COORDS_COMPACT.sub("", " ".join(room_coords)) - + # Append to room svg elements svg_rooms_elements.append( - f"""""") - + f"""""" + ) room_name = self._map_data.rooms[room].name if room_name != "Default": - # Calculate label positions (cannot use SVG transformations, as they are applied to the whole text, + # Calculate label positions (cannot use SVG transformations, as they are applied to the whole text, # which would result in text to be vertically flipped...) # Get a rough room center. - room_center_x = sum(float(x) for x in room_coords[0::2]) / (len(room_coords) / 2) - room_center_y = sum(float(y) for y in room_coords[1::2]) / (len(room_coords) / 2) + room_center_x = sum(float(x) for x in room_coords[0::2]) / ( + len(room_coords) / 2 + ) + room_center_y = sum(float(y) for y in room_coords[1::2]) / ( + len(room_coords) / 2 + ) # Get map relative position room_center_pos = _calc_point(room_center_x, room_center_y, image_box) - + # Add the text, with position vertically flipped on map center - svg_rooms_labels.append(f"""{room_name}""") - + style='font: 4pt sans-serif; user-select: none'>{room_name}""" + ) + return (svg_rooms_elements, svg_rooms_labels) - + def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -397,9 +430,9 @@ def get_svg_map(self, width: int | None = None) -> str: ): _LOGGER.debug("[get_svg_map] No need to update") return self._last_image.svg_image - + _LOGGER.debug("[get_svg_map] Begin") - + image = Image.new("RGBA", (6400, 6400)) draw = ImageDraw.ImageDraw(image) self._draw_map_pieces(draw) @@ -408,7 +441,10 @@ def get_svg_map(self, width: int | None = None) -> str: image_box = image.getbbox() if image_box: - image_box_center = ((image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2) + image_box_center = ( + (image_box[0] + image_box[2]) / 2, + (image_box[1] + image_box[3]) / 2, + ) _LOGGER.debug("[get_svg_map] Crop Image") cropped = image.crop(image_box) @@ -426,19 +462,24 @@ def get_svg_map(self, width: int | None = None) -> str: del cropped base64_bg = base64.b64encode(buffered.getvalue()) - + # Build the SVG XML - + svg_positions = _get_svg_positions(self._map_data.positions, image_box) - svg_subset_elements = [_get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values()] + svg_subset_elements = [ + _get_svg_subset(subset, image_box) + for subset in self._map_data.map_subsets.values() + ] - svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms(image_box, image_box_center) + svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms( + image_box, image_box_center + ) svg_traces_path = self._get_svg_traces_path() svg_map = f""" - @@ -460,7 +501,7 @@ def get_svg_map(self, width: int | None = None) -> str: - {"".join(svg_rooms_elements)} {"".join(svg_subset_elements)} @@ -473,14 +514,13 @@ def get_svg_map(self, width: int | None = None) -> str: else: # No map data yet, generate an empty SVG. svg_map = """""" - + self._map_data.reset_changed() self._last_image = LastImage(svg_map, width) _LOGGER.debug("[get_svg_map] Finish") return svg_map - async def teardown(self) -> None: """Teardown map.""" self.disable() @@ -549,6 +589,7 @@ def __eq__(self, obj: object) -> bool: @dataclasses.dataclass(frozen=True) class LastImage: """Last created image.""" + svg_image: str width: int | None @@ -570,7 +611,9 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList(on_change) + self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList( + on_change + ) @property def changed(self) -> bool: From 3ca558347942fbc702d9c51052c6427552c97f7e Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Thu, 14 Dec 2023 17:35:20 +0000 Subject: [PATCH 03/31] Migrated code to use an high-level library (svg.py) to build SVG elements. Fixed various typing hints. Code cleanups. --- deebot_client/map.py | 264 +++++++++++++++++++++++++++---------------- requirements.txt | 1 + 2 files changed, 167 insertions(+), 98 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index fec2ead8..e326178b 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -2,7 +2,7 @@ import ast import asyncio import base64 -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import UTC, datetime from io import BytesIO @@ -10,12 +10,14 @@ import lzma import re import struct +from textwrap import dedent from typing import Any, Final import zlib from numpy import float64, reshape, zeros from numpy.typing import NDArray from PIL import Image, ImageDraw +import svg from deebot_client.events.map import MapChangedEvent @@ -47,8 +49,6 @@ PositionType.CHARGER: 1, } -_SVG_COORDS_COMPACT = re.compile(r"(?:(?<=\D)\s)|(?:\s(?=\D))") - _SVG_MAP_MARGIN = 5 # Categorigal palette for 12 non related elements @@ -78,6 +78,31 @@ MapSetType.NO_MOP_ZONES: "#FFA500", } +# SVG definitions referred by map elements +_SVG_DEFS = svg.Defs( + text=dedent( + f""" + + + + + + + + + + + + + + + + + + """ + ) +) + def _decompress_7z_base64_data(data: str) -> bytes: _LOGGER.debug("[decompress7zBase64Data] Begin") @@ -100,7 +125,7 @@ def _decompress_7z_base64_data(data: str) -> bytes: return decompressed_data -def _calc_value(value: int, min_value: int, max_value: int) -> float: +def _calc_value(value: float, min_value: float, max_value: float) -> float: try: if value is not None: # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. @@ -115,7 +140,7 @@ def _calc_value(value: int, min_value: int, max_value: int) -> float: def _calc_point( - x: int, y: int, image_box: tuple[int, int, int, int] | None + x: float, y: float, image_box: tuple[float, float, float, float] | None ) -> tuple[float, float]: if image_box is None: image_box = (0, 0, x, y) @@ -126,58 +151,72 @@ def _calc_point( ) -def _points_to_svg_path(points: list[Any]) -> None: +def _points_to_svg_path( + points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool, int]], +) -> list[svg.PathData]: # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted # SVG path instruction. - path_points = [] - for prev_p, p in itertools.pairwise([None, *points]): + path_data: list[svg.PathData] = [] + + # First instruction: move to the starting point using absolute coordinates + first_p = points[0] + path_data.append(svg.MoveTo(first_p[0], first_p[1])) + + for prev_p, p in itertools.pairwise(points): if p != prev_p: # Skip repeated points - if prev_p: - # Relativize coords in order to generate compacted path - path_points.append("l" if len(p) == 2 or p[2] else "m") - path_points.extend(map(lambda a, b: str(a - b), p[0:2], prev_p[0:2])) + if len(p) == 2 or p[2]: + path_data.append(svg.LineToRel(p[0] - prev_p[0], p[1] - prev_p[1])) else: - # No previous point, use absolute coordinates for initial position - path_points.append("M") - path_points.extend(map(str, p[0:2])) + path_data.append(svg.MoveToRel(p[0] - prev_p[0], p[1] - prev_p[1])) # Further compact the path (keep only whitespaces between two numeric characters) - return _SVG_COORDS_COMPACT.sub("", " ".join(path_points)) + return path_data def _get_svg_positions( positions: list[Position], image_box: tuple[int, int, int, int] | None, -) -> str: - svg_positions = [] +) -> list[svg.Element]: + svg_positions: list[svg.Element] = [] for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): pos = _calc_point(position.x, position.y, image_box) svg_positions.append( - f"" + svg.Use(href=f"#position_{position.type}", x=pos[0], y=pos[1]) ) - return "".join(svg_positions) + return svg_positions def _get_svg_subset( subset: MapSubsetEvent, image_box: tuple[int, int, int, int] | None, -) -> str: - coordinates_ = ast.literal_eval(subset.coordinates) +) -> svg.Path | svg.Polygon: + subset_coordinates: list[int] = ast.literal_eval(subset.coordinates) - points: list[tuple[int, int]] = [ - _calc_point(coordinates_[i], coordinates_[i + 1], image_box) - for i in range(0, len(coordinates_), 2) + points = [ + _calc_point(subset_coordinates[i], subset_coordinates[i + 1], image_box) + for i in range(0, len(subset_coordinates), 2) ] - if len(points) == 4: - # Return polygon - svg_coords = list(sum(points, ())) - return f"""""" - # Return path - return f"""""" + if len(points) == 2: + # Only 2 point, use a path + return svg.Path( + stroke=_COLORS[subset.type], + stroke_width=1.5, + stroke_dasharray=[4], + vector_effect="non-scaling-stroke", + d=_points_to_svg_path(points), + ) + + # For any other points count, return a polygon that should fit any required shape + return svg.Polygon( + fill=_COLORS[subset.type] + "90", # Set alpha channel to 90 for fill color + stroke=_COLORS[subset.type], + stroke_width=1.5, + stroke_dasharray=[4], + vector_effect="non-scaling-stroke", + points=list(sum(points, [])), # Re-flatten the list of coordinates + ) class Map: @@ -238,16 +277,16 @@ def _update_trace_points(self, data: str) -> None: trace_points = _decompress_7z_base64_data(data) for i in range(0, len(trace_points), 5): - byte_position_x = struct.unpack("> 7 & 1 == 0 - type = point_data & 1 + point_type = point_data & 1 self._map_data.trace_values.append( - (byte_position_x[0], byte_position_y[0], connected, type) + (position_x, position_y, connected, point_type) ) _LOGGER.debug("[_update_trace_points] finish") @@ -283,23 +322,29 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: if pixel_type in [0x01, 0x02, 0x03]: draw.point((point_x, point_y), fill=_COLORS[pixel_type]) - def _get_svg_traces_path(self) -> str: + def _get_svg_traces_path(self) -> svg.Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - return f"""""" + return svg.Path( + fill="none", + stroke=_COLORS[_TRACE_MAP], + stroke_width=1.5, + stroke_linejoin="round", + vector_effect="non-scaling-stroke", + transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.2, 0.2)], + d=_points_to_svg_path(self._map_data.trace_values), + ) - return "" + return None def _get_svg_rooms( self, image_box: tuple[int, int, int, int], image_box_center: tuple[float, float], - ) -> tuple[list[str], list[str]]: - svg_rooms_elements = [] - svg_rooms_labels = [] + ) -> tuple[list[svg.Element], list[svg.Element]]: + svg_rooms_elements: list[svg.Element] = [] + svg_rooms_labels: list[svg.Element] = [] for room, color in zip( sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS) @@ -312,21 +357,23 @@ def _get_svg_rooms( ).decode("ascii"), ) - # SVG compacted presentation - svg_room_coords = _SVG_COORDS_COMPACT.sub("", " ".join(room_coords)) - # Append to room svg elements svg_rooms_elements.append( - f"""""" + svg.Polygon( + id=f"room_{room}", + fill=color + "50", + stroke=color + "A0", + stroke_width=2, + vector_effect="non-scaling-stroke", + transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.02, 0.02)], + points=list(map(int, room_coords)), + ) ) room_name = self._map_data.rooms[room].name if room_name != "Default": - # Calculate label positions (cannot use SVG transformations, as they are applied to the whole text, - # which would result in text to be vertically flipped...) + # Calculate label positions (cannot use SVG transformations to vertically flip coordinates, as transformations are + # applied to the whole text, which would result in text to be vertically flipped...) # Get a rough room center. room_center_x = sum(float(x) for x in room_coords[0::2]) / ( @@ -337,16 +384,21 @@ def _get_svg_rooms( ) # Get map relative position - room_center_pos = _calc_point(room_center_x, room_center_y, image_box) + room_center_p = _calc_point(room_center_x, room_center_y, image_box) # Add the text, with position vertically flipped on map center svg_rooms_labels.append( - f"""{room_name}""" + svg.Text( + id=f"room_label_{room}", + x=room_center_p[0], + y=image_box_center[1] - room_center_p[1] + image_box_center[1], + dominant_baseline="middle", + text_anchor="middle", + font_family="sans-serif", + font_size=svg.Length(4, "pt"), + style="user_select: none", + text=room_name, + ) ) return (svg_rooms_elements, svg_rooms_labels) @@ -467,7 +519,7 @@ def get_svg_map(self, width: int | None = None) -> str: svg_positions = _get_svg_positions(self._map_data.positions, image_box) - svg_subset_elements = [ + svg_subset_elements: list[svg.Element] = [ _get_svg_subset(subset, image_box) for subset in self._map_data.map_subsets.values() ] @@ -478,48 +530,64 @@ def get_svg_map(self, width: int | None = None) -> str: svg_traces_path = self._get_svg_traces_path() - svg_map = f""" - - - - - - - - - - - - - - - - - - - - - - - {"".join(svg_rooms_elements)} - {"".join(svg_subset_elements)} - {svg_traces_path} - {svg_positions} - - {"".join(svg_rooms_labels)} - - """ + # Elements of the SVG Map + svg_map_group_elements: list[svg.Element] = [] + + # Map background. + svg_map_group_elements.append( + svg.Image( + x=image_box[0], + y=image_box[1], + width=image_box[2] - image_box[0], + height=image_box[3] - image_box[1], + style="image-rendering: pixelated", + href=f"data:image/png;base64,{base64_bg.decode('ascii')}", + ) + ) + + # Rooms + svg_map_group_elements.extend(svg_rooms_elements) + + # Additional subsets (VirtualWalls and NoMopZones) + svg_map_group_elements.extend(svg_subset_elements) + + # Traces (if any) + if svg_traces_path: + svg_map_group_elements.append(svg_traces_path) + + # Bot and Charge stations + svg_map_group_elements.extend(svg_positions) + + # Build the complete SVG map + svg_map = svg.SVG( + viewBox=svg.ViewBoxSpec( + image_box[0] - _SVG_MAP_MARGIN, + image_box[1] - _SVG_MAP_MARGIN, + (image_box[2] - image_box[0]) + _SVG_MAP_MARGIN * 2, + image_box[3] - image_box[1] + _SVG_MAP_MARGIN * 2, + ), + elements=[ + _SVG_DEFS, + svg.G( + id="map_group", + transform_origin=f"{image_box_center[0]} {image_box_center[1]}", + transform=[svg.Scale(1, -1)], + elements=svg_map_group_elements, + ), + svg.G(elements=svg_rooms_labels), + ], + ) + else: # No map data yet, generate an empty SVG. - svg_map = """""" + svg_map = svg.SVG() + str_svg_map = str(svg_map) self._map_data.reset_changed() - self._last_image = LastImage(svg_map, width) + self._last_image = LastImage(str_svg_map, width) _LOGGER.debug("[get_svg_map] Finish") - return svg_map + return str_svg_map async def teardown(self) -> None: """Teardown map.""" diff --git a/requirements.txt b/requirements.txt index 25bd0299..22ee78dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ cachetools>=5.0.0,<6.0 defusedxml numpy>=1.23.2,<2.0 Pillow>=10.0.1,<11.0 +svg.py>=1.4.2 From c1d022850d4b1525551c4c4f96825832e3c92a86 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 16 Dec 2023 12:18:51 +0000 Subject: [PATCH 04/31] Optimize map pieces --- deebot_client/map.py | 58 +++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 04d315d9..a0539454 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -12,9 +12,7 @@ from typing import Any, Final import zlib -from numpy import float64, reshape, zeros -from numpy.typing import NDArray -from PIL import Image, ImageDraw, ImageOps +from PIL import Image, ImageColor, ImageDraw, ImageOps, ImagePalette from deebot_client.events.map import MapChangedEvent @@ -54,6 +52,16 @@ MapSetType.VIRTUAL_WALLS: "#FF0000", MapSetType.NO_MOP_ZONES: "#FFA500", } +_MAP_BACKGROUND_COLORS = [ + "#000000", # 0 -> transparent + "#badaff", # 1 ->floor + "#4e96e2", # 2 ->wall + "#1a81ed", # 3 -> carpet +] +_MAP_BACKGROUND_IMAGE_PALETTE = ImagePalette.ImagePalette( + "RGB", + [value for color in _MAP_BACKGROUND_COLORS for value in ImageColor.getrgb(color)], +) def _decompress_7z_base64_data(data: str) -> bytes: @@ -206,7 +214,7 @@ def _update_trace_points(self, data: str) -> None: _LOGGER.debug("[_update_trace_points] finish") - def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: + def _draw_map_pieces(self, image: Image.Image) -> None: _LOGGER.debug("[_draw_map_pieces] Draw") image_x = 0 image_y = 0 @@ -221,21 +229,7 @@ def _draw_map_pieces(self, draw: ImageDraw.ImageDraw) -> None: current_piece = self._map_data.map_pieces[i] if current_piece.in_use: - for x in range(100): - current_column = current_piece.points[x] - for y in range(100): - pixel_type = current_column[y] - point_x = image_x + x - point_y = image_y + y - if (point_x > 6400) or (point_y > 6400): - _LOGGER.error( - "[get_base64_map] Map Limit 6400!! X: %d Y: %d", - point_x, - point_y, - ) - raise MapError("Map Limit reached!") - if pixel_type in [0x01, 0x02, 0x03]: - draw.point((point_x, point_y), fill=_COLORS[pixel_type]) + image.paste(current_piece.image, (image_x, image_y)) def enable(self) -> None: """Enable map.""" @@ -321,7 +315,12 @@ def get_base64_map(self, width: int | None = None) -> bytes: image = Image.new("RGBA", (6400, 6400)) draw = DashedImageDraw(image) - self._draw_map_pieces(draw) + # After switching to svg + # im = Image.new("P", (6400, 6400)) + # im.putpalette(_MAP_BACKGROUND_IMAGE_PALETTE) + # im.info["transparency"] = 0 + + self._draw_map_pieces(image) # Draw Trace Route if len(self._map_data.trace_values) > 0: @@ -398,15 +397,15 @@ class MapPiece: def __init__(self, on_change: Callable[[], None], index: int) -> None: self._on_change = on_change self._index = index - self._points: NDArray[float64] | None = None self._crc32: int = MapPiece._NOT_INUSE_CRC32 + self._image: Image.Image | None = None def crc32_indicates_update(self, crc32: str) -> bool: """Return True if update is required.""" crc32_int = int(crc32) if crc32_int == MapPiece._NOT_INUSE_CRC32: self._crc32 = crc32_int - self._points = None + self._image = None return False return self._crc32 != crc32_int @@ -417,11 +416,11 @@ def in_use(self) -> bool: return self._crc32 != MapPiece._NOT_INUSE_CRC32 @property - def points(self) -> NDArray[float64]: + def image(self) -> Image.Image: """I'm the 'x' property.""" - if not self.in_use or self._points is None: - return zeros((100, 100)) - return self._points + if not self.in_use or self._image is None: + return Image.new("P", (100, 100)) + return self._image def update_points(self, base64_data: str) -> None: """Add map piece points.""" @@ -433,9 +432,12 @@ def update_points(self, base64_data: str) -> None: self._on_change() if self.in_use: - self._points = reshape(list(decoded), (100, 100)) + im = Image.frombytes("P", (100, 100), decoded, "raw", "P", 0, -1) + im.putpalette(_MAP_BACKGROUND_IMAGE_PALETTE) + im.info["transparency"] = 0 + self._image = im.rotate(-90) else: - self._points = None + self._image = None def __hash__(self) -> int: """Calculate hash on index and crc32.""" From a44a420a8e32936d964f6d0aa9428084f9918c85 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 16 Dec 2023 12:21:15 +0000 Subject: [PATCH 05/31] improve comment --- deebot_client/map.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index a0539454..d5451323 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -53,9 +53,9 @@ MapSetType.NO_MOP_ZONES: "#FFA500", } _MAP_BACKGROUND_COLORS = [ - "#000000", # 0 -> transparent - "#badaff", # 1 ->floor - "#4e96e2", # 2 ->wall + "#000000", # 0 -> unknown (will be transparent) + "#badaff", # 1 -> floor + "#4e96e2", # 2 -> wall "#1a81ed", # 3 -> carpet ] _MAP_BACKGROUND_IMAGE_PALETTE = ImagePalette.ImagePalette( From f89fd0786c2eb7195205fd2e0abed576430047bb Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 14:10:47 +0100 Subject: [PATCH 06/31] Apply suggestions from code review Co-authored-by: Robert Resch --- deebot_client/map.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index e326178b..0e9c1bc0 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -490,9 +490,8 @@ def get_svg_map(self, width: int | None = None) -> str: self._draw_map_pieces(draw) del draw - image_box = image.getbbox() - if image_box: + if image_box := image.getbbox(): image_box_center = ( (image_box[0] + image_box[2]) / 2, (image_box[1] + image_box[3]) / 2, @@ -578,11 +577,8 @@ def get_svg_map(self, width: int | None = None) -> str: ], ) - else: - # No map data yet, generate an empty SVG. - svg_map = svg.SVG() - str_svg_map = str(svg_map) + str_svg_map = str(svg_map or svg.SVG()) self._map_data.reset_changed() self._last_image = LastImage(str_svg_map, width) _LOGGER.debug("[get_svg_map] Finish") From dc1d9bcacf07a120064bcc666e4550a508522df3 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:00:38 +0000 Subject: [PATCH 07/31] Converted static Defs to svg.py elements. Fixed some comments. Fixed a pylance error on svg_map to str conversion. --- deebot_client/map.py | 115 +++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 47 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 0e9c1bc0..f941221c 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -10,7 +10,6 @@ import lzma import re import struct -from textwrap import dedent from typing import Any, Final import zlib @@ -80,27 +79,48 @@ # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( - text=dedent( - f""" - - - - - - - - - - - - - - - - - - """ - ) + elements=[ + # Gradient used by Bot icon + svg.RadialGradient( + id="device_bg", + cx=svg.Length(50, "%"), + cy=svg.Length(50, "%"), + r=svg.Length(50, "%"), + fx=svg.Length(50, "%"), + fy=svg.Length(50, "%"), + elements=[ + svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#0000FF;"), + svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#0000FF00;"), + ], + ), + # Bot circular icon + svg.G( + id=f"position_{PositionType.DEEBOT}", + elements=[ + svg.Circle(r=5, fill="url(#device_bg)"), + svg.Circle(r=3.5, stroke="white", fill="blue", stroke_width=0.5), + ], + ), + # Charger pin icon (pre-flipped vertically) + svg.G( + id=f"position_{PositionType.CHARGER}", + transform=[svg.Scale(4, -4)], + elements=[ + svg.Path( + fill="#ffe605", + d=[ + svg.M(1, -1.6), + svg.C(1, -1.05, 0, 0, 0, 0), + svg.c(0, 0, -1, -1.05, -1, -1.6), + svg.c(0, -0.55, 0.45, -1, 1, -1), + svg.c(0.55, 0, 1, 0.45, 1, 1), + svg.Z(), + ], + ), + svg.Circle(fill="white", r=0.7, cy=-1.6, cx=0), + ], + ), + ] ) @@ -154,8 +174,8 @@ def _calc_point( def _points_to_svg_path( points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool, int]], ) -> list[svg.PathData]: - # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to a compacted - # SVG path instruction. + # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to + # SVG path instructions. path_data: list[svg.PathData] = [] # First instruction: move to the starting point using absolute coordinates @@ -169,7 +189,6 @@ def _points_to_svg_path( else: path_data.append(svg.MoveToRel(p[0] - prev_p[0], p[1] - prev_p[1])) - # Further compact the path (keep only whitespaces between two numeric characters) return path_data @@ -490,7 +509,7 @@ def get_svg_map(self, width: int | None = None) -> str: self._draw_map_pieces(draw) del draw - + svg_map = svg.SVG() if image_box := image.getbbox(): image_box_center = ( (image_box[0] + image_box[2]) / 2, @@ -514,7 +533,7 @@ def get_svg_map(self, width: int | None = None) -> str: base64_bg = base64.b64encode(buffered.getvalue()) - # Build the SVG XML + # Build the SVG elements svg_positions = _get_svg_positions(self._map_data.positions, image_box) @@ -529,7 +548,7 @@ def get_svg_map(self, width: int | None = None) -> str: svg_traces_path = self._get_svg_traces_path() - # Elements of the SVG Map + # Elements of the SVG Map to vertically flip svg_map_group_elements: list[svg.Element] = [] # Map background. @@ -557,30 +576,32 @@ def get_svg_map(self, width: int | None = None) -> str: # Bot and Charge stations svg_map_group_elements.extend(svg_positions) - # Build the complete SVG map - svg_map = svg.SVG( - viewBox=svg.ViewBoxSpec( - image_box[0] - _SVG_MAP_MARGIN, - image_box[1] - _SVG_MAP_MARGIN, - (image_box[2] - image_box[0]) + _SVG_MAP_MARGIN * 2, - image_box[3] - image_box[1] + _SVG_MAP_MARGIN * 2, - ), - elements=[ - _SVG_DEFS, - svg.G( - id="map_group", - transform_origin=f"{image_box_center[0]} {image_box_center[1]}", - transform=[svg.Scale(1, -1)], - elements=svg_map_group_elements, - ), - svg.G(elements=svg_rooms_labels), - ], + # Set map viewBox based on background map bounding box. + svg_map.viewBox = svg.ViewBoxSpec( + image_box[0] - _SVG_MAP_MARGIN, + image_box[1] - _SVG_MAP_MARGIN, + (image_box[2] - image_box[0]) + _SVG_MAP_MARGIN * 2, + image_box[3] - image_box[1] + _SVG_MAP_MARGIN * 2, ) + # Add all elements to the SVG map + svg_map.elements = [ + _SVG_DEFS, + # Elements to vertically flip + svg.G( + transform_origin=f"{image_box_center[0]} {image_box_center[1]}", + transform=[svg.Scale(1, -1)], + elements=svg_map_group_elements, + ), + # Elements with already flipped coordinates. + svg.G(elements=svg_rooms_labels), + ] + + str_svg_map = str(svg_map) - str_svg_map = str(svg_map or svg.SVG()) self._map_data.reset_changed() self._last_image = LastImage(str_svg_map, width) + _LOGGER.debug("[get_svg_map] Finish") return str_svg_map From 672b9596d578614137611db82e2acdf1faf995ec Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:11:04 +0000 Subject: [PATCH 08/31] Removed room rendering --- deebot_client/map.py | 91 -------------------------------------------- 1 file changed, 91 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index f941221c..6bd281b6 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -8,7 +8,6 @@ from io import BytesIO import itertools import lzma -import re import struct from typing import Any, Final import zlib @@ -50,22 +49,6 @@ _SVG_MAP_MARGIN = 5 -# Categorigal palette for 12 non related elements -_ROOM_COLORS = [ - "#a6cee3", - "#1f78b4", - "#b2df8a", - "#33a02c", - "#fb9a99", - "#e31a1c", - "#fdbf6f", - "#ff7f00", - "#cab2d6", - "#6a3d9a", - "#ffff99", - "#b15928", -] - _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { @@ -357,71 +340,6 @@ def _get_svg_traces_path(self) -> svg.Path | None: return None - def _get_svg_rooms( - self, - image_box: tuple[int, int, int, int], - image_box_center: tuple[float, float], - ) -> tuple[list[svg.Element], list[svg.Element]]: - svg_rooms_elements: list[svg.Element] = [] - svg_rooms_labels: list[svg.Element] = [] - - for room, color in zip( - sorted(self._map_data.rooms.keys()), itertools.cycle(_ROOM_COLORS) - ): - # Split coordinates into a flat sequence - room_coords = re.split( - "[;,]", - _decompress_7z_base64_data( - self._map_data.rooms[room].coordinates - ).decode("ascii"), - ) - - # Append to room svg elements - svg_rooms_elements.append( - svg.Polygon( - id=f"room_{room}", - fill=color + "50", - stroke=color + "A0", - stroke_width=2, - vector_effect="non-scaling-stroke", - transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.02, 0.02)], - points=list(map(int, room_coords)), - ) - ) - - room_name = self._map_data.rooms[room].name - if room_name != "Default": - # Calculate label positions (cannot use SVG transformations to vertically flip coordinates, as transformations are - # applied to the whole text, which would result in text to be vertically flipped...) - - # Get a rough room center. - room_center_x = sum(float(x) for x in room_coords[0::2]) / ( - len(room_coords) / 2 - ) - room_center_y = sum(float(y) for y in room_coords[1::2]) / ( - len(room_coords) / 2 - ) - - # Get map relative position - room_center_p = _calc_point(room_center_x, room_center_y, image_box) - - # Add the text, with position vertically flipped on map center - svg_rooms_labels.append( - svg.Text( - id=f"room_label_{room}", - x=room_center_p[0], - y=image_box_center[1] - room_center_p[1] + image_box_center[1], - dominant_baseline="middle", - text_anchor="middle", - font_family="sans-serif", - font_size=svg.Length(4, "pt"), - style="user_select: none", - text=room_name, - ) - ) - - return (svg_rooms_elements, svg_rooms_labels) - def enable(self) -> None: """Enable map.""" if self._unsubscribers: @@ -542,10 +460,6 @@ def get_svg_map(self, width: int | None = None) -> str: for subset in self._map_data.map_subsets.values() ] - svg_rooms_elements, svg_rooms_labels = self._get_svg_rooms( - image_box, image_box_center - ) - svg_traces_path = self._get_svg_traces_path() # Elements of the SVG Map to vertically flip @@ -563,9 +477,6 @@ def get_svg_map(self, width: int | None = None) -> str: ) ) - # Rooms - svg_map_group_elements.extend(svg_rooms_elements) - # Additional subsets (VirtualWalls and NoMopZones) svg_map_group_elements.extend(svg_subset_elements) @@ -593,8 +504,6 @@ def get_svg_map(self, width: int | None = None) -> str: transform=[svg.Scale(1, -1)], elements=svg_map_group_elements, ), - # Elements with already flipped coordinates. - svg.G(elements=svg_rooms_labels), ] str_svg_map = str(svg_map) From 21a83e4bd6eba9e7fcc22c877a6a6a255feb0834 Mon Sep 17 00:00:00 2001 From: Luca De Petrillo <972242+lukakama@users.noreply.github.com> Date: Sat, 16 Dec 2023 16:45:19 +0000 Subject: [PATCH 09/31] Converted test_calc_point to floating point precision (for SVG) --- tests/test_map.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_map.py b/tests/test_map.py index cc56e326..f49b2dc6 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -15,9 +15,9 @@ from deebot_client.models import Room _test_calc_point_data = [ - (0, 10, None, (0, 10)), - (10, 100, (100, 0, 200, 50), (200, 50)), - (10, 100, (0, 0, 1000, 1000), (400, 402)), + (0, 10, None, (0.0, 10.0)), + (10, 100, (100, 0, 200, 50), (200.0, 50.0)), + (10, 100, (0, 0, 1000, 1000), (400.2, 402.0)), ] @@ -26,7 +26,7 @@ def test_calc_point( x: int, y: int, image_box: tuple[int, int, int, int] | None, - expected: tuple[int, int], + expected: tuple[float, float], ) -> None: result = _calc_point(x, y, image_box) assert result == expected From db69f91ca830035a56582b6ea98d1796035f7927 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 18 Dec 2023 18:40:05 +0000 Subject: [PATCH 10/31] Reduce unnecessary spaces in svg lib --- deebot_client/map.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 36f3838d..2aa29c56 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -37,6 +37,32 @@ from .models import Room from .util import OnChangedDict, OnChangedList, cancel, create_task + +def _path_data_str(self) -> str: # type: ignore[no-untyped-def] # noqa: ANN001 + points = [] + for p in dataclasses.astuple(self): + value = p + if isinstance(p, bool): + value = int(p) + points.append(str(value)) + joined = " ".join(points) + return f"{self.command}{joined}" + + +svg.PathData.__str__ = _path_data_str # type: ignore[method-assign] + + +@dataclasses.dataclass +class Path(svg.Path): + """Path which removes unnecessary spaces.""" + + @classmethod + def _as_str(cls, val: Any) -> str: + if isinstance(val, list) and val and isinstance(val[0], svg.PathData): + return "".join(cls._as_str(v) for v in val) + return super()._as_str(val) + + _LOGGER = get_logger(__name__) _PIXEL_WIDTH = 50 @@ -97,7 +123,7 @@ id=f"position_{PositionType.CHARGER}", transform=[svg.Scale(4, -4)], elements=[ - svg.Path( + Path( fill="#ffe605", d=[ svg.M(1, -1.6), @@ -210,7 +236,7 @@ def _get_svg_subset( if len(points) == 2: # Only 2 point, use a path - return svg.Path( + return Path( stroke=_COLORS[subset.type], stroke_width=1.5, stroke_dasharray=[4], @@ -322,7 +348,7 @@ def _get_svg_traces_path(self) -> svg.Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - return svg.Path( + return Path( fill="none", stroke=_COLORS[_TRACE_MAP], stroke_width=1.5, From 2ac2e7366dc5f87fed38d3daf20fc246bd126fcd Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 18 Dec 2023 18:43:11 +0000 Subject: [PATCH 11/31] use NamedTuple --- deebot_client/map.py | 53 +++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 2aa29c56..7a01bc69 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -9,7 +9,7 @@ import itertools import lzma import struct -from typing import Any, Final +from typing import Any, Final, NamedTuple import zlib from PIL import Image, ImageColor, ImagePalette @@ -94,6 +94,23 @@ def _as_str(cls, val: Any) -> str: [value for color in _MAP_BACKGROUND_COLORS for value in ImageColor.getrgb(color)], ) + +class Point(NamedTuple): + """Point.""" + + x: float + y: float + + +class TracePoint(NamedTuple): + """Trace point.""" + + x: int + y: int + connected: bool + type: int + + # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( elements=[ @@ -178,18 +195,18 @@ def _calc_value(value: float, min_value: float, max_value: float) -> float: def _calc_point( x: float, y: float, image_box: tuple[float, float, float, float] | None -) -> tuple[float, float]: +) -> Point: if image_box is None: image_box = (0, 0, x, y) - return ( + return Point( _calc_value(x, image_box[0], image_box[2]), _calc_value(y, image_box[1], image_box[3]), ) def _points_to_svg_path( - points: Sequence[tuple[float, float]] | Sequence[tuple[float, float, bool, int]], + points: Sequence[Point | TracePoint], ) -> list[svg.PathData]: # Convert a set of simple point (x, y), or trace points (x, y, connected, type) to # SVG path instructions. @@ -197,15 +214,21 @@ def _points_to_svg_path( # First instruction: move to the starting point using absolute coordinates first_p = points[0] - path_data.append(svg.MoveTo(first_p[0], first_p[1])) + path_data.append(svg.MoveTo(first_p.x, first_p.y)) for prev_p, p in itertools.pairwise(points): - if p != prev_p: # Skip repeated points - if len(p) == 2 or p[2]: - path_data.append(svg.LineToRel(p[0] - prev_p[0], p[1] - prev_p[1])) - else: - path_data.append(svg.MoveToRel(p[0] - prev_p[0], p[1] - prev_p[1])) - + x = p.x - prev_p.x + y = p.y - prev_p.y + if isinstance(p, TracePoint) and not p.connected: + path_data.append(svg.MoveToRel(x, y)) + elif x == 0: + if y == 0: + continue + path_data.append(svg.VerticalLineToRel(y)) + elif y == 0: + path_data.append(svg.HorizontalLineToRel(x)) + else: + path_data.append(svg.LineToRel(x, y)) return path_data @@ -322,7 +345,7 @@ def _update_trace_points(self, data: str) -> None: point_type = point_data & 1 self._map_data.trace_values.append( - (position_x, position_y, connected, point_type) + TracePoint(position_x, position_y, connected, point_type) ) _LOGGER.debug("[_update_trace_points] finish") @@ -628,9 +651,7 @@ def on_change() -> None: self._map_subsets: OnChangedDict[int, MapSubsetEvent] = OnChangedDict(on_change) self._positions: OnChangedList[Position] = OnChangedList(on_change) self._rooms: OnChangedDict[int, Room] = OnChangedDict(on_change) - self._trace_values: OnChangedList[tuple[int, int, bool, int]] = OnChangedList( - on_change - ) + self._trace_values: OnChangedList[TracePoint] = OnChangedList(on_change) @property def changed(self) -> bool: @@ -665,7 +686,7 @@ def rooms(self) -> dict[int, Room]: return self._rooms @property - def trace_values(self) -> OnChangedList[tuple[int, int, bool, int]]: + def trace_values(self) -> OnChangedList[TracePoint]: """Return trace values.""" return self._trace_values From a27d58976fc860ce28e473467140257954b3239e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 18 Dec 2023 18:43:45 +0000 Subject: [PATCH 12/31] use one struct.unpack --- deebot_client/map.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 7a01bc69..d84a4c1b 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -336,8 +336,7 @@ def _update_trace_points(self, data: str) -> None: trace_points = _decompress_7z_base64_data(data) for i in range(0, len(trace_points), 5): - position_x: int = struct.unpack(" Date: Sat, 23 Dec 2023 11:40:24 +0000 Subject: [PATCH 13/31] Remove point type --- deebot_client/map.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index f4f63f38..7f0a231d 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -106,7 +106,6 @@ class TracePoint(NamedTuple): x: int y: int connected: bool - type: int # SVG definitions referred by map elements @@ -338,10 +337,9 @@ def _update_trace_points(self, data: str) -> None: point_data = trace_points[i + 4] connected = point_data >> 7 & 1 == 0 - point_type = point_data & 1 self._map_data.trace_values.append( - TracePoint(position_x, position_y, connected, point_type) + TracePoint(position_x, position_y, connected) ) _LOGGER.debug("[_update_trace_points] finish") From 94dd06e0acd735b871cf7ef02805ff03149335b9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 11:57:29 +0000 Subject: [PATCH 14/31] Flip image before adding to svg --- deebot_client/map.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 7f0a231d..68648938 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -12,7 +12,7 @@ from typing import Any, Final, NamedTuple import zlib -from PIL import Image, ImageColor, ImagePalette +from PIL import Image, ImageColor, ImageOps, ImagePalette import svg from deebot_client.events.map import MapChangedEvent @@ -74,9 +74,6 @@ def _as_str(cls, val: Any) -> str: _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { - 0x01: "#badaff", # floor - 0x02: "#4e96e2", # wall - 0x03: "#1a81ed", # carpet _TRACE_MAP: "#FFFFFF", MapSetType.VIRTUAL_WALLS: "#FF0000", MapSetType.NO_MOP_ZONES: "#FFA500", @@ -472,7 +469,7 @@ def get_svg_map(self, width: int | None = None) -> str: ) _LOGGER.debug("[get_svg_map] Crop Image") - cropped = image.crop(image_box) + cropped = ImageOps.flip(image.crop(image_box)) del image _LOGGER.debug( @@ -489,12 +486,10 @@ def get_svg_map(self, width: int | None = None) -> str: base64_bg = base64.b64encode(buffered.getvalue()) # Build the SVG elements - - # Elements of the SVG Map to vertically flip - svg_map_group_elements: list[svg.Element] = [] + svg_map.elements = [_SVG_DEFS] # Map background. - svg_map_group_elements.append( + svg_map.elements.append( svg.Image( x=image_box[0], y=image_box[1], @@ -505,6 +500,9 @@ def get_svg_map(self, width: int | None = None) -> str: ) ) + # Elements of the SVG Map to vertically flip + svg_map_group_elements: list[svg.Element] = [] + # Additional subsets (VirtualWalls and NoMopZones) svg_map_group_elements.extend( [ @@ -531,15 +529,14 @@ def get_svg_map(self, width: int | None = None) -> str: ) # Add all elements to the SVG map - svg_map.elements = [ - _SVG_DEFS, + svg_map.elements.append( # Elements to vertically flip svg.G( transform_origin=f"{image_box_center[0]} {image_box_center[1]}", transform=[svg.Scale(1, -1)], elements=svg_map_group_elements, - ), - ] + ) + ) str_svg_map = str(svg_map) @@ -601,8 +598,6 @@ def update_points(self, base64_data: str) -> None: if self.in_use: im = Image.frombytes("P", (100, 100), decoded, "raw", "P", 0, -1) - im.putpalette(_MAP_BACKGROUND_IMAGE_PALETTE) - im.info["transparency"] = 0 self._image = im.rotate(-90) else: self._image = None From 8b00bd3c228c4f0e084493eb5b0e7e75a6451124 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 13:40:04 +0000 Subject: [PATCH 15/31] use our Path --- deebot_client/map.py | 10 +++++----- pyproject.toml | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 68648938..99358c99 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -53,7 +53,7 @@ def _path_data_str(self) -> str: # type: ignore[no-untyped-def] # noqa: ANN001 @dataclasses.dataclass -class Path(svg.Path): +class Path(svg.Path): # noqa: TID251 """Path which removes unnecessary spaces.""" @classmethod @@ -133,7 +133,7 @@ class TracePoint(NamedTuple): svg.G( id=f"position_{PositionType.CHARGER}", elements=[ - svg.Path( + Path( fill="#ffe605", d=[ svg.M(4, 6.4), @@ -242,7 +242,7 @@ def _get_svg_positions( def _get_svg_subset( subset: MapSubsetEvent, image_box: tuple[int, int, int, int] | None, -) -> svg.Path | svg.Polygon: +) -> Path | svg.Polygon: subset_coordinates: list[int] = ast.literal_eval(subset.coordinates) points = [ @@ -358,11 +358,11 @@ def _draw_map_pieces(self, image: Image.Image) -> None: if current_piece.in_use: image.paste(current_piece.image, (image_x, image_y)) - def _get_svg_traces_path(self) -> svg.Path | None: + def _get_svg_traces_path(self) -> Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - return svg.Path( + return Path( fill="none", stroke=_COLORS[_TRACE_MAP], stroke_width=1.5, diff --git a/pyproject.toml b/pyproject.toml index ba8ab611..ad9a710c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,10 @@ ignore = [ "T201", # print found ] +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"svg.Path".msg = "Use map.Path instead" + + [tool.ruff.mccabe] max-complexity = 12 From de7a057b3029e3b2882cd498bbb88dc323497d69 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 18:43:04 +0000 Subject: [PATCH 16/31] use shorthand notation --- deebot_client/map.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 99358c99..d379de29 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -74,9 +74,9 @@ def _as_str(cls, val: Any) -> str: _OFFSET = 400 _TRACE_MAP = "trace_map" _COLORS = { - _TRACE_MAP: "#FFFFFF", - MapSetType.VIRTUAL_WALLS: "#FF0000", - MapSetType.NO_MOP_ZONES: "#FFA500", + _TRACE_MAP: "#fff", + MapSetType.VIRTUAL_WALLS: "#f00", + MapSetType.NO_MOP_ZONES: "#ffa500", } _MAP_BACKGROUND_COLORS = [ "#000000", # 0 -> unknown (will be transparent) @@ -117,8 +117,8 @@ class TracePoint(NamedTuple): fx=svg.Length(50, "%"), fy=svg.Length(50, "%"), elements=[ - svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#0000FF;"), - svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#0000FF00;"), + svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#00f;"), + svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#00f0;"), ], ), # Bot circular icon @@ -144,7 +144,7 @@ class TracePoint(NamedTuple): svg.Z(), ], ), - svg.Circle(fill="white", r=2.8, cy=6.4, cx=0), + svg.Circle(fill="#fff", r=2.8, cy=6.4), ], ), ] From b11a5f228fd7e73ff3da43b80c2a1d5076ccaa16 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 18:44:20 +0000 Subject: [PATCH 17/31] optimize transformation and round values --- deebot_client/map.py | 148 +++++++++++++++++++++++++++---------------- 1 file changed, 93 insertions(+), 55 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index d379de29..5a7fb4a1 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -65,6 +65,7 @@ def _as_str(cls, val: Any) -> str: _LOGGER = get_logger(__name__) _PIXEL_WIDTH = 50 +_ROUND_TO_DIGITS = 3 _POSITIONS_SVG_ORDER = { PositionType.DEEBOT: 0, @@ -105,6 +106,30 @@ class TracePoint(NamedTuple): connected: bool +@dataclasses.dataclass +class AxisManipulation: + """Map manipulation.""" + + map_shift: float + svg_max: float + _transform: Callable[[float, float], float] + + def __post_init__(self) -> None: + self._svg_center = self.svg_max / 2 + + def transform(self, value: float) -> float: + """Transform value.""" + return self._transform(self._svg_center, value) + + +@dataclasses.dataclass +class MapManipulation: + """Map manipulation.""" + + x: AxisManipulation + y: AxisManipulation + + # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( elements=[ @@ -172,29 +197,33 @@ def _decompress_7z_base64_data(data: str) -> bytes: return decompressed_data -def _calc_value(value: float, min_value: float, max_value: float) -> float: +def _calc_value(value: float, axis_manipulation: AxisManipulation) -> float: try: if value is not None: # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. - new_value = (float(value) / _PIXEL_WIDTH) + _OFFSET + new_value = ( + (float(value) / _PIXEL_WIDTH) + _OFFSET - axis_manipulation.map_shift + ) + new_value = axis_manipulation.transform(new_value) # return value inside min and max - return min(max_value, max(min_value, new_value)) + return round( + min(axis_manipulation.svg_max, max(0, new_value)), _ROUND_TO_DIGITS + ) except (ZeroDivisionError, ValueError): pass - return min_value or 0 + return 0 def _calc_point( - x: float, y: float, image_box: tuple[float, float, float, float] | None + x: float, + y: float, + map_manipulation: MapManipulation, ) -> Point: - if image_box is None: - image_box = (0, 0, x, y) - return Point( - _calc_value(x, image_box[0], image_box[2]), - _calc_value(y, image_box[1], image_box[3]), + _calc_value(x, map_manipulation.x), + _calc_value(y, map_manipulation.y), ) @@ -210,8 +239,8 @@ def _points_to_svg_path( path_data.append(svg.MoveTo(first_p.x, first_p.y)) for prev_p, p in itertools.pairwise(points): - x = p.x - prev_p.x - y = p.y - prev_p.y + x = round(p.x - prev_p.x, _ROUND_TO_DIGITS) + y = round(p.y - prev_p.y, _ROUND_TO_DIGITS) if isinstance(p, TracePoint) and not p.connected: path_data.append(svg.MoveToRel(x, y)) elif x == 0: @@ -227,11 +256,11 @@ def _points_to_svg_path( def _get_svg_positions( positions: list[Position], - image_box: tuple[int, int, int, int] | None, + map_manipulation: MapManipulation, ) -> list[svg.Element]: svg_positions: list[svg.Element] = [] for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): - pos = _calc_point(position.x, position.y, image_box) + pos = _calc_point(position.x, position.y, map_manipulation) svg_positions.append( svg.Use(href=f"#position_{position.type}", x=pos[0], y=pos[1]) ) @@ -241,12 +270,16 @@ def _get_svg_positions( def _get_svg_subset( subset: MapSubsetEvent, - image_box: tuple[int, int, int, int] | None, + map_manipulation: MapManipulation, ) -> Path | svg.Polygon: subset_coordinates: list[int] = ast.literal_eval(subset.coordinates) points = [ - _calc_point(subset_coordinates[i], subset_coordinates[i + 1], image_box) + _calc_point( + subset_coordinates[i], + subset_coordinates[i + 1], + map_manipulation, + ) for i in range(0, len(subset_coordinates), 2) ] @@ -358,17 +391,25 @@ def _draw_map_pieces(self, image: Image.Image) -> None: if current_piece.in_use: image.paste(current_piece.image, (image_x, image_y)) - def _get_svg_traces_path(self) -> Path | None: + def _get_svg_traces_path( + self, + map_manipulation: MapManipulation, + ) -> Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") - return Path( fill="none", stroke=_COLORS[_TRACE_MAP], stroke_width=1.5, stroke_linejoin="round", vector_effect="non-scaling-stroke", - transform=[svg.Translate(_OFFSET, _OFFSET), svg.Scale(0.2, 0.2)], + transform=[ + svg.Translate( + _OFFSET - map_manipulation.x.map_shift, + _OFFSET - map_manipulation.y.map_shift, + ), + svg.Scale(0.2, 0.2), + ], d=_points_to_svg_path(self._map_data.trace_values), ) @@ -463,11 +504,6 @@ def get_svg_map(self, width: int | None = None) -> str: svg_map = svg.SVG() if image_box := image.getbbox(): - image_box_center = ( - (image_box[0] + image_box[2]) / 2, - (image_box[1] + image_box[3]) / 2, - ) - _LOGGER.debug("[get_svg_map] Crop Image") cropped = ImageOps.flip(image.crop(image_box)) del image @@ -487,55 +523,57 @@ def get_svg_map(self, width: int | None = None) -> str: # Build the SVG elements svg_map.elements = [_SVG_DEFS] + manipulation = MapManipulation( + AxisManipulation( + map_shift=image_box[0], + svg_max=image_box[2] - image_box[0], + _transform=lambda _, y: y, + ), + AxisManipulation( + map_shift=image_box[1], + svg_max=image_box[3] - image_box[1], + _transform=lambda x, y: 2 * x - y, + ), + ) + + # Set map viewBox based on background map bounding box. + svg_map.viewBox = svg.ViewBoxSpec( + 0, + 0, + manipulation.x.svg_max, + manipulation.y.svg_max, + ) # Map background. svg_map.elements.append( svg.Image( - x=image_box[0], - y=image_box[1], - width=image_box[2] - image_box[0], - height=image_box[3] - image_box[1], style="image-rendering: pixelated", href=f"data:image/png;base64,{base64_bg.decode('ascii')}", ) ) - # Elements of the SVG Map to vertically flip - svg_map_group_elements: list[svg.Element] = [] - # Additional subsets (VirtualWalls and NoMopZones) - svg_map_group_elements.extend( + svg_map.elements.extend( [ - _get_svg_subset(subset, image_box) + _get_svg_subset(subset, manipulation) for subset in self._map_data.map_subsets.values() ] ) # Traces (if any) - if svg_traces_path := self._get_svg_traces_path(): - svg_map_group_elements.append(svg_traces_path) + if svg_traces_path := self._get_svg_traces_path(manipulation): + svg_map.elements.append( + # Elements to vertically flip + svg.G( + transform_origin=r"50% 50%", + transform=[svg.Scale(1, -1)], + elements=[svg_traces_path], + ) + ) # Bot and Charge stations - svg_map_group_elements.extend( - _get_svg_positions(self._map_data.positions, image_box) - ) - - # Set map viewBox based on background map bounding box. - svg_map.viewBox = svg.ViewBoxSpec( - image_box[0], - image_box[1], - image_box[2] - image_box[0], - image_box[3] - image_box[1], - ) - - # Add all elements to the SVG map - svg_map.elements.append( - # Elements to vertically flip - svg.G( - transform_origin=f"{image_box_center[0]} {image_box_center[1]}", - transform=[svg.Scale(1, -1)], - elements=svg_map_group_elements, - ) + svg_map.elements.extend( + _get_svg_positions(self._map_data.positions, manipulation) ) str_svg_map = str(svg_map) From a7de74f3d65e5b532ec8fb49610826bbb3535d02 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 19:15:12 +0000 Subject: [PATCH 18/31] fix test --- tests/test_map.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/test_map.py b/tests/test_map.py index f49b2dc6..485cdd07 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -11,13 +11,19 @@ Position, PositionType, ) -from deebot_client.map import Map, MapData, _calc_point +from deebot_client.map import ( + AxisManipulation, + Map, + MapData, + MapManipulation, + Point, + _calc_point, +) from deebot_client.models import Room _test_calc_point_data = [ - (0, 10, None, (0.0, 10.0)), - (10, 100, (100, 0, 200, 50), (200.0, 50.0)), - (10, 100, (0, 0, 1000, 1000), (400.2, 402.0)), + (10, 100, (100, 0, 200, 50), Point(100.0, 0.0)), + (10, 100, (0, 0, 1000, 1000), Point(400.2, 598.0)), ] @@ -25,10 +31,22 @@ def test_calc_point( x: int, y: int, - image_box: tuple[int, int, int, int] | None, - expected: tuple[float, float], + image_box: tuple[int, int, int, int], + expected: Point, ) -> None: - result = _calc_point(x, y, image_box) + manipulation = MapManipulation( + AxisManipulation( + map_shift=image_box[0], + svg_max=image_box[2] - image_box[0], + _transform=lambda _, y: y, + ), + AxisManipulation( + map_shift=image_box[1], + svg_max=image_box[3] - image_box[1], + _transform=lambda x, y: 2 * x - y, + ), + ) + result = _calc_point(x, y, manipulation) assert result == expected From 8ed2155259e54454fff787bacf6a176b5c47cd6e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 19:24:25 +0000 Subject: [PATCH 19/31] Use dataclass instead --- deebot_client/map.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 5a7fb4a1..ea93be0d 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -9,7 +9,7 @@ import itertools import lzma import struct -from typing import Any, Final, NamedTuple +from typing import Any, Final import zlib from PIL import Image, ImageColor, ImageOps, ImagePalette @@ -91,18 +91,22 @@ def _as_str(cls, val: Any) -> str: ) -class Point(NamedTuple): +@dataclasses.dataclass(frozen=True) +class Point: """Point.""" x: float y: float + def flatten(self) -> tuple[float, float]: + """Flatten point.""" + return (self.x, self.y) + -class TracePoint(NamedTuple): +@dataclasses.dataclass(frozen=True) +class TracePoint(Point): """Trace point.""" - x: int - y: int connected: bool @@ -262,7 +266,7 @@ def _get_svg_positions( for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): pos = _calc_point(position.x, position.y, map_manipulation) svg_positions.append( - svg.Use(href=f"#position_{position.type}", x=pos[0], y=pos[1]) + svg.Use(href=f"#position_{position.type}", x=pos.x, y=pos.y) ) return svg_positions @@ -300,7 +304,7 @@ def _get_svg_subset( stroke_width=1.5, stroke_dasharray=[4], vector_effect="non-scaling-stroke", - points=list(sum(points, [])), # Re-flatten the list of coordinates + points=[num for p in points for num in p.flatten()], ) From 976d776bec8b957307aabe957ebf977804e34ca3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 19:52:45 +0000 Subject: [PATCH 20/31] shorten ids --- deebot_client/map.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index ea93be0d..2c4234e8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -67,9 +67,16 @@ def _as_str(cls, val: Any) -> str: _PIXEL_WIDTH = 50 _ROUND_TO_DIGITS = 3 -_POSITIONS_SVG_ORDER = { - PositionType.DEEBOT: 0, - PositionType.CHARGER: 1, + +@dataclasses.dataclass(frozen=True) +class _PositionSvg: + order: int + svg_id: str + + +_POSITIONS_SVG = { + PositionType.DEEBOT: _PositionSvg(0, "d"), + PositionType.CHARGER: _PositionSvg(1, "c"), } _OFFSET = 400 @@ -139,7 +146,7 @@ class MapManipulation: elements=[ # Gradient used by Bot icon svg.RadialGradient( - id="device_bg", + id=f"{_POSITIONS_SVG[PositionType.DEEBOT].svg_id}bg", cx=svg.Length(50, "%"), cy=svg.Length(50, "%"), r=svg.Length(50, "%"), @@ -152,15 +159,17 @@ class MapManipulation: ), # Bot circular icon svg.G( - id=f"position_{PositionType.DEEBOT}", + id=_POSITIONS_SVG[PositionType.DEEBOT].svg_id, elements=[ - svg.Circle(r=5, fill="url(#device_bg)"), + svg.Circle( + r=5, fill=f"url(#{_POSITIONS_SVG[PositionType.DEEBOT].svg_id}bg)" + ), svg.Circle(r=3.5, stroke="white", fill="blue", stroke_width=0.5), ], ), # Charger pin icon (pre-flipped vertically) svg.G( - id=f"position_{PositionType.CHARGER}", + id=_POSITIONS_SVG[PositionType.CHARGER].svg_id, elements=[ Path( fill="#ffe605", @@ -263,10 +272,10 @@ def _get_svg_positions( map_manipulation: MapManipulation, ) -> list[svg.Element]: svg_positions: list[svg.Element] = [] - for position in sorted(positions, key=lambda x: _POSITIONS_SVG_ORDER[x.type]): + for position in sorted(positions, key=lambda x: _POSITIONS_SVG[x.type].order): pos = _calc_point(position.x, position.y, map_manipulation) svg_positions.append( - svg.Use(href=f"#position_{position.type}", x=pos.x, y=pos.y) + svg.Use(href=f"#{_POSITIONS_SVG[position.type].svg_id}", x=pos.x, y=pos.y) ) return svg_positions From 29e8349a56b528c21f73ce1fb2ce6630b42c0e3d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 19:55:28 +0000 Subject: [PATCH 21/31] fix charger icon --- deebot_client/map.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 2c4234e8..3c7b6c2f 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -174,15 +174,15 @@ class MapManipulation: Path( fill="#ffe605", d=[ - svg.M(4, 6.4), - svg.C(4, 4.2, 0, 0, 0, 0), - svg.C(0, 0, -4, 4.2, -4, 6.4), - svg.C(-4, 8.6, -2.2, 10.4, 0, 10.4), - svg.C(2.2, 10.4, 4, 8.6, 4, 6.4), + svg.M(4, -6.4), + svg.C(4, -4.2, 0, 0, 0, 0), + svg.C(0, 0, -4, -4.2, -4, -6.4), + svg.C(-4, -8.6, -2.2, -10.4, 0, -10.4), + svg.C(2.2, -10.4, 4, -8.6, 4, -6.4), svg.Z(), ], ), - svg.Circle(fill="#fff", r=2.8, cy=6.4), + svg.Circle(fill="#fff", r=2.8, cy=-6.4), ], ), ] From 3deb06ef11483debabc99f044217d44eb39bc335 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 20:06:16 +0000 Subject: [PATCH 22/31] Use svggo to optimize further --- deebot_client/map.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 3c7b6c2f..edf75592 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -153,8 +153,8 @@ class MapManipulation: fx=svg.Length(50, "%"), fy=svg.Length(50, "%"), elements=[ - svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#00f;"), - svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#00f0;"), + svg.Stop(offset=svg.Length(70, "%"), style="stop-color:#00f"), + svg.Stop(offset=svg.Length(97, "%"), style="stop-color:#00f0"), ], ), # Bot circular icon @@ -176,9 +176,9 @@ class MapManipulation: d=[ svg.M(4, -6.4), svg.C(4, -4.2, 0, 0, 0, 0), - svg.C(0, 0, -4, -4.2, -4, -6.4), - svg.C(-4, -8.6, -2.2, -10.4, 0, -10.4), - svg.C(2.2, -10.4, 4, -8.6, 4, -6.4), + svg.s(-4, -4.2, -4, -6.4), + svg.s(1.8, -4, 4, -4), + svg.s(4, 1.8, 4, 4), svg.Z(), ], ), From ee5b04aa00b4d15df5534b780068a44bd09e2449 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 23 Dec 2023 20:48:49 +0000 Subject: [PATCH 23/31] Optimize svg string --- deebot_client/map.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index edf75592..48e0d36c 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import UTC, datetime +from decimal import Decimal from io import BytesIO import itertools import lzma @@ -38,18 +39,22 @@ from .util import OnChangedDict, OnChangedList, cancel, create_task -def _path_data_str(self) -> str: # type: ignore[no-untyped-def] # noqa: ANN001 - points = [] +def _attributes_as_str(self) -> str: # type: ignore[no-untyped-def] # noqa: ANN001 + """Return attributes as compact svg string.""" + result = "" for p in dataclasses.astuple(self): value = p if isinstance(p, bool): value = int(p) - points.append(str(value)) - joined = " ".join(points) - return f"{self.command}{joined}" + if result == "" or (isinstance(value, Decimal | float | int) and value < 0): + result += f"{value}" + else: + # only positive values need to have a space + result += f" {value}" + return result -svg.PathData.__str__ = _path_data_str # type: ignore[method-assign] +svg.PathData.attributes_as_str = _attributes_as_str # type: ignore[attr-defined] @dataclasses.dataclass @@ -59,7 +64,23 @@ class Path(svg.Path): # noqa: TID251 @classmethod def _as_str(cls, val: Any) -> str: if isinstance(val, list) and val and isinstance(val[0], svg.PathData): - return "".join(cls._as_str(v) for v in val) + result = "" + current = None + for elem in val: + if hasattr(elem, "attributes_as_str"): + attributes = elem.attributes_as_str() + # if the command is the same as the previous one, we can omit it + if current != elem.command: + current = elem.command + result += elem.command + elif attributes[0] != "-": + # only positive values need to have a space + result += " " + result += elem.attributes_as_str() + else: + current = None + result += cls._as_str(elem) + return result return super()._as_str(val) From cf5a8437c88abd8ac5dca70494a0bbdb81caccc1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Dec 2023 12:50:18 +0000 Subject: [PATCH 24/31] create color palette dynamically --- deebot_client/map.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 48e0d36c..f1adf0e6 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -107,16 +107,14 @@ class _PositionSvg: MapSetType.VIRTUAL_WALLS: "#f00", MapSetType.NO_MOP_ZONES: "#ffa500", } -_MAP_BACKGROUND_COLORS = [ - "#000000", # 0 -> unknown (will be transparent) - "#badaff", # 1 -> floor - "#4e96e2", # 2 -> wall - "#1a81ed", # 3 -> carpet -] -_MAP_BACKGROUND_IMAGE_PALETTE = ImagePalette.ImagePalette( - "RGB", - [value for color in _MAP_BACKGROUND_COLORS for value in ImageColor.getrgb(color)], -) +_DEFAULT_MAP_BACKGROUND_COLOR = ImageColor.getrgb("#badaff") # floor +_MAP_BACKGROUND_COLORS = { + 0: ImageColor.getrgb("#000000"), # unknown (will be transparent) + 1: _DEFAULT_MAP_BACKGROUND_COLOR, # floor + 2: ImageColor.getrgb("#4e96e2"), # wall + 3: ImageColor.getrgb("#1a81ed"), # carpet + # fallsback to _DEFAULT_MAP_BACKGROUND_COLOR for any other value +} @dataclasses.dataclass(frozen=True) @@ -338,6 +336,23 @@ def _get_svg_subset( ) +def _set_image_palette(image: Image.Image) -> None: + """Dynamically create color palette for map image.""" + palette_colors: list[int] = [] + for value in [c[1] for c in image.getcolors()]: + palette_colors.extend( + _MAP_BACKGROUND_COLORS.get(value, _DEFAULT_MAP_BACKGROUND_COLOR) + ) + + image.putpalette( + ImagePalette.ImagePalette( + "RGB", + palette_colors, + ) + ) + image.info["transparency"] = 0 + + class Map: """Map representation.""" @@ -532,9 +547,8 @@ def get_svg_map(self, width: int | None = None) -> str: _LOGGER.debug("[get_svg_map] Begin") image = Image.new("P", (6400, 6400)) - image.putpalette(_MAP_BACKGROUND_IMAGE_PALETTE) - image.info["transparency"] = 0 self._draw_map_pieces(image) + _set_image_palette(image) svg_map = svg.SVG() if image_box := image.getbbox(): From 1e9e5aebbbd85201ddd94abf1846dde4cc812ce2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 24 Dec 2023 13:18:04 +0000 Subject: [PATCH 25/31] Some command names need to written always --- deebot_client/map.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index f1adf0e6..cb61ea7b 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -56,6 +56,11 @@ def _attributes_as_str(self) -> str: # type: ignore[no-untyped-def] # noqa: ANN svg.PathData.attributes_as_str = _attributes_as_str # type: ignore[attr-defined] +_ALWAYS_WRITE_COMMAND_NAME: tuple[str, ...] = ( + svg.MoveTo.command, + svg.MoveToRel.command, +) + @dataclasses.dataclass class Path(svg.Path): # noqa: TID251 @@ -70,7 +75,10 @@ def _as_str(cls, val: Any) -> str: if hasattr(elem, "attributes_as_str"): attributes = elem.attributes_as_str() # if the command is the same as the previous one, we can omit it - if current != elem.command: + if ( + current != elem.command + or elem.command in _ALWAYS_WRITE_COMMAND_NAME + ): current = elem.command result += elem.command elif attributes[0] != "-": From c5dd24905279fcfa53ce21952a8ff91cafecdff4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 26 Dec 2023 22:16:11 +0000 Subject: [PATCH 26/31] Copied new background values from #372 Co-authored-by: mvladislav --- deebot_client/map.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index cb61ea7b..ec4c0e74 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -116,11 +116,13 @@ class _PositionSvg: MapSetType.NO_MOP_ZONES: "#ffa500", } _DEFAULT_MAP_BACKGROUND_COLOR = ImageColor.getrgb("#badaff") # floor -_MAP_BACKGROUND_COLORS = { +_MAP_BACKGROUND_COLORS: dict[int, tuple[int, ...]] = { 0: ImageColor.getrgb("#000000"), # unknown (will be transparent) 1: _DEFAULT_MAP_BACKGROUND_COLOR, # floor 2: ImageColor.getrgb("#4e96e2"), # wall 3: ImageColor.getrgb("#1a81ed"), # carpet + 4: ImageColor.getrgb("#dee9fb"), # not scanned space + 5: ImageColor.getrgb("#edf3fb"), # possible obstacle # fallsback to _DEFAULT_MAP_BACKGROUND_COLOR for any other value } From a19d85e9f93d373ce983245f8f8574009748efec Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 27 Dec 2023 19:18:26 +0000 Subject: [PATCH 27/31] fix background and move it to own function --- deebot_client/map.py | 78 ++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index ec4c0e74..1053ec44 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -170,6 +170,14 @@ class MapManipulation: y: AxisManipulation +@dataclasses.dataclass +class BackgroundImage: + """Background image.""" + + bounding_box: tuple[float, float, float, float] + image: bytes + + # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( elements=[ @@ -346,22 +354,21 @@ def _get_svg_subset( ) -def _set_image_palette(image: Image.Image) -> None: +def _set_image_palette(image: Image.Image) -> Image.Image: """Dynamically create color palette for map image.""" palette_colors: list[int] = [] - for value in [c[1] for c in image.getcolors()]: + for idx in range(256): palette_colors.extend( - _MAP_BACKGROUND_COLORS.get(value, _DEFAULT_MAP_BACKGROUND_COLOR) + _MAP_BACKGROUND_COLORS.get(idx, _DEFAULT_MAP_BACKGROUND_COLOR) ) + source_palette = ImagePalette.ImagePalette("RGB", palette_colors) - image.putpalette( - ImagePalette.ImagePalette( - "RGB", - palette_colors, - ) - ) image.info["transparency"] = 0 + return image.remap_palette( + [c[1] for c in image.getcolors()], source_palette.tobytes() + ) + class Map: """Map representation.""" @@ -541,6 +548,26 @@ def refresh(self) -> None: self._event_bus.request_refresh(MapTraceEvent) self._event_bus.request_refresh(MajorMapEvent) + def _get_background_image(self) -> BackgroundImage | None: + """Return background image.""" + image = Image.new("P", (6400, 6400)) + self._draw_map_pieces(image) + + bounding_box = image.getbbox() + if bounding_box is None: + return None + + image = ImageOps.flip(image.crop(bounding_box)) + image = _set_image_palette(image) + + buffered = BytesIO() + image.save(buffered, format="PNG", optimize=True) + + return BackgroundImage( + bounding_box, + buffered.getvalue(), + ) + def get_svg_map(self, width: int | None = None) -> str: """Return map as SVG string.""" if not self._unsubscribers: @@ -556,40 +583,19 @@ def get_svg_map(self, width: int | None = None) -> str: _LOGGER.debug("[get_svg_map] Begin") - image = Image.new("P", (6400, 6400)) - self._draw_map_pieces(image) - _set_image_palette(image) - svg_map = svg.SVG() - if image_box := image.getbbox(): - _LOGGER.debug("[get_svg_map] Crop Image") - cropped = ImageOps.flip(image.crop(image_box)) - del image - - _LOGGER.debug( - "[get_svg_map] Map current Size: X: %d Y: %d", - cropped.size[0], - cropped.size[1], - ) - - _LOGGER.debug("[get_svg_map] Saving to buffer") - buffered = BytesIO() - cropped.save(buffered, format="PNG") - del cropped - - base64_bg = base64.b64encode(buffered.getvalue()) - + if background := self._get_background_image(): # Build the SVG elements svg_map.elements = [_SVG_DEFS] manipulation = MapManipulation( AxisManipulation( - map_shift=image_box[0], - svg_max=image_box[2] - image_box[0], + map_shift=background.bounding_box[0], + svg_max=background.bounding_box[2] - background.bounding_box[0], _transform=lambda _, y: y, ), AxisManipulation( - map_shift=image_box[1], - svg_max=image_box[3] - image_box[1], + map_shift=background.bounding_box[1], + svg_max=background.bounding_box[3] - background.bounding_box[1], _transform=lambda x, y: 2 * x - y, ), ) @@ -606,7 +612,7 @@ def get_svg_map(self, width: int | None = None) -> str: svg_map.elements.append( svg.Image( style="image-rendering: pixelated", - href=f"data:image/png;base64,{base64_bg.decode('ascii')}", + href=f"data:image/png;base64,{base64.b64encode(background.image).decode('ascii')}", ) ) From 1f12bc9285d153b15154ff28a5b37a376765b1ff Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 28 Dec 2023 13:22:28 +0000 Subject: [PATCH 28/31] Add path test --- tests/test_map.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_map.py b/tests/test_map.py index 485cdd07..63d9e80f 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -2,6 +2,7 @@ from unittest.mock import ANY, AsyncMock, Mock, call import pytest +import svg from deebot_client.event_bus import EventBus from deebot_client.events.map import ( @@ -16,6 +17,7 @@ Map, MapData, MapManipulation, + Path, Point, _calc_point, ) @@ -87,3 +89,23 @@ async def test_Map_internal_subscriptions( await map.teardown() assert not map._unsubscribers_internal + + +def test_compact_path() -> None: + """Test that the path is compacted correctly.""" + path = Path( + fill="#ffe605", + d=[ + svg.M(4, -6.4), + svg.C(4, -4.2, 0, 0, 0, 0), + svg.s(-4, -4.2, -4, -6.4), + svg.l(0, -3.2), + svg.l(4, 0), + svg.Z(), + ], + ) + + assert ( + str(path) + == '' + ) From cd02569c32428fdaf7313215d1e70a9c869f2c5a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 28 Dec 2023 14:57:24 +0000 Subject: [PATCH 29/31] Add test for _points_to_svg_path --- deebot_client/map.py | 4 +-- tests/test_map.py | 64 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index 1053ec44..fe8218da 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -291,11 +291,11 @@ def _points_to_svg_path( for prev_p, p in itertools.pairwise(points): x = round(p.x - prev_p.x, _ROUND_TO_DIGITS) y = round(p.y - prev_p.y, _ROUND_TO_DIGITS) + if x == 0 and y == 0: + continue if isinstance(p, TracePoint) and not p.connected: path_data.append(svg.MoveToRel(x, y)) elif x == 0: - if y == 0: - continue path_data.append(svg.VerticalLineToRel(y)) elif y == 0: path_data.append(svg.HorizontalLineToRel(x)) diff --git a/tests/test_map.py b/tests/test_map.py index 63d9e80f..47907461 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -1,8 +1,19 @@ import asyncio +from collections.abc import Sequence from unittest.mock import ANY, AsyncMock, Mock, call import pytest -import svg +from svg import ( + ClosePath, + CubicBezier, + HorizontalLineToRel, + LineToRel, + MoveTo, + MoveToRel, + PathData, + SmoothCubicBezierRel, + VerticalLineToRel, +) from deebot_client.event_bus import EventBus from deebot_client.events.map import ( @@ -19,7 +30,9 @@ MapManipulation, Path, Point, + TracePoint, _calc_point, + _points_to_svg_path, ) from deebot_client.models import Room @@ -96,12 +109,12 @@ def test_compact_path() -> None: path = Path( fill="#ffe605", d=[ - svg.M(4, -6.4), - svg.C(4, -4.2, 0, 0, 0, 0), - svg.s(-4, -4.2, -4, -6.4), - svg.l(0, -3.2), - svg.l(4, 0), - svg.Z(), + MoveTo(4, -6.4), + CubicBezier(4, -4.2, 0, 0, 0, 0), + SmoothCubicBezierRel(-4, -4.2, -4, -6.4), + LineToRel(0, -3.2), + LineToRel(4, 0), + ClosePath(), ], ) @@ -109,3 +122,40 @@ def test_compact_path() -> None: str(path) == '' ) + + +@pytest.mark.parametrize( + ("points", "expected"), + [ + ( + [Point(x=45.58, y=176.12), Point(x=18.78, y=175.94)], + [MoveTo(45.58, 176.12), LineToRel(-26.8, -0.18)], + ), + ( + [ + TracePoint(x=-215, y=-70, connected=False), + TracePoint(x=-215, y=-70, connected=True), + TracePoint(x=-212, y=-73, connected=True), + TracePoint(x=-213, y=-73, connected=True), + TracePoint(x=-227, y=-72, connected=True), + TracePoint(x=-227, y=-70, connected=True), + TracePoint(x=-227, y=-70, connected=True), + TracePoint(x=-256, y=-69, connected=False), + TracePoint(x=-260, y=-80, connected=True), + ], + [ + MoveTo(x=-215, y=-70), + LineToRel(dx=3, dy=-3), + HorizontalLineToRel(dx=-1), + LineToRel(dx=-14, dy=1), + VerticalLineToRel(dy=2), + MoveToRel(dx=-29, dy=1), + LineToRel(dx=-4, dy=-11), + ], + ), + ], +) +def test_points_to_svg_path( + points: Sequence[Point | TracePoint], expected: list[PathData] +) -> None: + assert _points_to_svg_path(points) == expected From 4a103ca71b8b6d5056645826f6fa8f7f73a6f04e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 28 Dec 2023 15:01:22 +0000 Subject: [PATCH 30/31] Improve test --- tests/test_map.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_map.py b/tests/test_map.py index 47907461..94849568 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -4,6 +4,7 @@ import pytest from svg import ( + ArcRel, ClosePath, CubicBezier, HorizontalLineToRel, @@ -114,13 +115,14 @@ def test_compact_path() -> None: SmoothCubicBezierRel(-4, -4.2, -4, -6.4), LineToRel(0, -3.2), LineToRel(4, 0), + ArcRel(1, 2, 3, large_arc=True, sweep=False, dx=4, dy=5), ClosePath(), ], ) assert ( str(path) - == '' + == '' ) From 60ddf03afbbcbcf69cba0232bffe92b2552d655e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 28 Dec 2023 15:14:58 +0000 Subject: [PATCH 31/31] Improve tests and AxisManipulation --- deebot_client/map.py | 7 ++++--- tests/test_map.py | 26 ++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/deebot_client/map.py b/deebot_client/map.py index fe8218da..c2225dc8 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -152,13 +152,15 @@ class AxisManipulation: map_shift: float svg_max: float - _transform: Callable[[float, float], float] + _transform: Callable[[float, float], float] | None = None def __post_init__(self) -> None: self._svg_center = self.svg_max / 2 def transform(self, value: float) -> float: """Transform value.""" + if self._transform is None: + return value return self._transform(self._svg_center, value) @@ -591,12 +593,11 @@ def get_svg_map(self, width: int | None = None) -> str: AxisManipulation( map_shift=background.bounding_box[0], svg_max=background.bounding_box[2] - background.bounding_box[0], - _transform=lambda _, y: y, ), AxisManipulation( map_shift=background.bounding_box[1], svg_max=background.bounding_box[3] - background.bounding_box[1], - _transform=lambda x, y: 2 * x - y, + _transform=lambda c, v: 2 * c - v, ), ) diff --git a/tests/test_map.py b/tests/test_map.py index 94849568..f1b4fc80 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -40,6 +40,7 @@ _test_calc_point_data = [ (10, 100, (100, 0, 200, 50), Point(100.0, 0.0)), (10, 100, (0, 0, 1000, 1000), Point(400.2, 598.0)), + (None, 100, (0, 0, 1000, 1000), Point(0, 598.0)), ] @@ -54,18 +55,39 @@ def test_calc_point( AxisManipulation( map_shift=image_box[0], svg_max=image_box[2] - image_box[0], - _transform=lambda _, y: y, ), AxisManipulation( map_shift=image_box[1], svg_max=image_box[3] - image_box[1], - _transform=lambda x, y: 2 * x - y, + _transform=lambda c, v: 2 * c - v, ), ) result = _calc_point(x, y, manipulation) assert result == expected +@pytest.mark.parametrize(("error"), [ValueError(), ZeroDivisionError()]) +def test_calc_point_exceptions( + error: Exception, +) -> None: + def transform(_: float, __: float) -> float: + raise error + + manipulation = MapManipulation( + AxisManipulation( + map_shift=50, + svg_max=100, + _transform=transform, + ), + AxisManipulation( + map_shift=50, + svg_max=100, + ), + ) + result = _calc_point(100, 100, manipulation) + assert result == Point(0, 100) + + async def test_MapData(event_bus: EventBus) -> None: mock = AsyncMock() event_bus.subscribe(MapChangedEvent, mock)