import logging from io import BytesIO from typing import Union, List from PIL import ImageDraw, Image class ImageProcessor(object): """Класс для обработки изображений (нарезки и сравнения)""" RED = "red" GREEN = "green" BLUE = "blue" ALPHA = "alpha" # https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L121 # https://github.com/rsmbl/Resemble.js/blob/dec5ae1cf1d10c9027a94400a81c17d025a9d3b6/resemble.js#L981 tolerance = { RED: 32, GREEN: 32, BLUE: 32, ALPHA: 32, } def __init__(self): self._block_width = 40 # default self._block_height = 40 def _slice_image(self, image: Image.Image) -> List[dict]: """Нарезать картинки на блоки""" max_width, max_height = image.size # нижний правый угол для кропа width_change = self._block_width height_change = self._block_height result = [] for row in range(0, max_height, self._block_height): for col in range(0, max_width, self._block_width): # Если дошли до края изображения if width_change > max_width: width_change = max_width # отрезаем по краю if height_change > max_height: height_change = max_height # col, row -- верхний левый угол box = (col, row, width_change, height_change) cropped = image.crop(box) result.append({"image": cropped, "box": box}) # сдвигаем вправо по ширине width_change += self._block_width # сдвигаем вниз по высоте height_change += self._block_height # возвращаем указатель на ширину в стартовую позицию width_change = self._block_width return result def _is_color_similar(self, a, b, color): """Проверить похожесть цветов. Для того, чтобы тесты не тригеррились на антиалиазинг допуски в self.tolerance. """ if a is None and b is None: return True diff = abs(a - b) if diff == 0: return True elif diff < self.tolerance[color]: return True return False def _compare_images(self, image_one: Image.Image, image_two: Image.Image) -> bool: """Сравнить два изображения попиксельно""" assert image_one.size == image_two.size, \ f"Картинки должны быть одинакового размера, {image_one.size} {image_two.size}" max_width, max_height = image_one.size for coord_y in range(0, max_height): for coord_x in range(0, max_width): pixel_one = image_one.getpixel((coord_x, coord_y)) pixel_two = image_two.getpixel((coord_x, coord_y)) equal = self._compare_pixels(pixel_one, pixel_two) if not equal: return False return True def _compare_pixels(self, pixel_one, pixel_two) -> bool: """Сравнить каждый цвет, каждого писклея.""" assert len(pixel_one) == len(pixel_two), f"В одном из пикселей не хватает цветов: {pixel_one} {pixel_two}" for item in zip(pixel_one, pixel_two, (self.RED, self.GREEN, self.BLUE, self.ALPHA)): color_one, color_two, color = item if not self._is_color_similar(color_one, color_two, color): return False return True def get_images_diff(self, first_image: Image.Image, second_image: Image.Image) -> List[Union[int, bytes]]: """Поблочно сравнить два изображения и вернуть количество блоков с несовпавшими пикселями""" result_image = first_image.copy() first_image_blocks = self._slice_image(first_image) second_image_blocks = self._slice_image(second_image) # если скриншоты разных размеров, то все блоки из большего скриншота, которые не попали в меньший нужно добавить # к битым mistaken_blocks = abs(len(first_image_blocks) - len(second_image_blocks)) for index in range(min(len(first_image_blocks), len(second_image_blocks))): image_equal = self._compare_images(first_image_blocks[index]["image"], second_image_blocks[index]["image"]) if not image_equal: draw = ImageDraw.Draw(result_image) draw.rectangle(first_image_blocks[index]["box"], outline="red") mistaken_blocks += 1 return [mistaken_blocks, self.image_to_bytes(result_image)] def paste(self, screenshots: List[bytes], over_height: int) -> Image.Image: """Склеить массив скриншотов в одно изображение""" max_width = 0 max_height = 0 images = [] for screenshot in screenshots: image = self.load_image_from_bytes(screenshot) images.append(image) max_width = image.size[0] if image.size[0] > max_width else max_width max_height += image.size[1] # Склейка работает так: сначала создаем одно "пустое" изображение равное размеру всех скелееных, и вставляем в него по одному все скриншоты. # Чтобы в финальном скрине не получилось что скриншоты заняли меньше места, чем картинка, снизу отрезаем over_height. max_height = max_height - over_height result = Image.new('RGB', (max_width, max_height)) logging.info(f'Screen size: ({max_width}, {max_height})') offset = 0 last_image_index = len(images) - 1 for index, image in enumerate(images): # Расскоментить если нужно посмотреть какие скрины склеиваются в один # with open(f"screen-{index}.png", "wb") as fp: # image.save(fp) if last_image_index == index and over_height != 0: # с последнего скриншота срезаем ту часть, в которой он дублирует предпоследний logging.info(f"Crop over height: {over_height}") image = image.crop((0, over_height, image.size[0], image.size[1])) result.paste(image, (0, offset)) logging.info(f"Image added, offset is {offset}") offset += image.size[1] return result @staticmethod def load_image_from_bytes(data: bytes) -> Image.Image: """Загрузить изображение из байтовой строки.""" with BytesIO(data) as fp: image: Image.Image = Image.open(fp) image.load() return image @staticmethod def image_to_bytes(image: Image.Image) -> bytes: with BytesIO() as fp: image.save(fp, "PNG") return fp.getvalue()