From 8308940674c406a4927966a6c6f1ee8de3621e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Capelle?= Date: Sun, 15 Dec 2024 13:37:17 +0100 Subject: [PATCH] Allow creation of avi video. --- poetry.lock | 30 +++- pyproject.toml | 1 + src/holt59/aoc/2024/day15.py | 242 +++++++++++++++--------------- src/holt59/aoc/base.py | 43 +++++- src/holt59/aoc/utils/api/files.py | 16 +- src/holt59/aoc/utils/files.py | 11 +- 6 files changed, 207 insertions(+), 136 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9f84788..3c91740 100644 --- a/poetry.lock +++ b/poetry.lock @@ -584,6 +584,30 @@ files = [ {file = "numpy-2.2.0.tar.gz", hash = "sha256:140dd80ff8981a583a60980be1a655068f8adebf7a45a06a6858c873fcdcd4a0"}, ] +[[package]] +name = "opencv-python" +version = "4.10.0.84" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +files = [ + {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, +] + [[package]] name = "ortools" version = "9.11.4210" @@ -689,9 +713,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -1503,4 +1527,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "1b586d7f23aca41e499ce94ec10eacfab3c2e260bb16822638ae1f831b2988be" +content-hash = "5b57bccd8dc65a9acecbe187939bae625ef6a259f4188c6587907245bcfa604f" diff --git a/pyproject.toml b/pyproject.toml index aa8044c..a507296 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ networkx = "^3.4.2" pillow = "^11.0.0" imageio = "^2.36.1" pygifsicle = "^1.1.0" +opencv-python = "^4.10.0.84" [tool.poetry.group.dev.dependencies] pyright = "^1.1.389" diff --git a/src/holt59/aoc/2024/day15.py b/src/holt59/aoc/2024/day15.py index 502ad9b..4525e3a 100644 --- a/src/holt59/aoc/2024/day15.py +++ b/src/holt59/aoc/2024/day15.py @@ -5,108 +5,114 @@ from numpy.typing import NDArray from ..base import BaseSolver -Robot: TypeAlias = tuple[int, int] -Grid: TypeAlias = list[list[int]] ImageGrid: TypeAlias = NDArray[np.uint8] -class BoxUtils: +class Grid: FREE: Final = 0 BLOCK: Final = 1 ROBOT: Final = 2 - @staticmethod - def convert(grid_s: list[str], large: bool): + robot: tuple[int, int] + + def __init__(self, grid_s: list[str], large: bool): grid: list[list[int]] = [] robot: tuple[int, int] | None = None box_counter = 4 if not large else 5 for i_row, row in enumerate(grid_s): row_u: list[int] = [] for i_col, col in enumerate(row): - if col == "." or col == "@": - row_u.extend( - (BoxUtils.FREE, BoxUtils.FREE) if large else (BoxUtils.FREE,) - ) - robot = (i_row, i_col) + if col in ".@": + row_u.extend((Grid.FREE, Grid.FREE) if large else (Grid.FREE,)) + if col == "@": + robot = (i_row, i_col * 2 if large else i_col) elif col == "#": - row_u.extend( - (BoxUtils.BLOCK, BoxUtils.BLOCK) if large else (BoxUtils.BLOCK,) - ) + row_u.extend((Grid.BLOCK, Grid.BLOCK) if large else (Grid.BLOCK,)) else: row_u.extend( (box_counter, -box_counter) if large else (box_counter,) ) box_counter += 2 grid.append(row_u) + + self.grid = np.array(grid) + assert robot is not None - return grid, robot + self.robot = robot - @staticmethod - def is_box(c: int): - return c >= 4 and c % 2 == 0 + @property + def n_rows(self): + return len(self.grid) - @staticmethod - def is_open_or_close_box(c: int): - return abs(c) >= 4 and c % 2 == 1 + @property + def n_columns(self): + return len(self.grid[0]) - @staticmethod - def is_open_box(c: int): - return c >= 4 and c % 2 == 1 + def __len__(self): + return self.n_rows - @staticmethod - def is_close_box(c: int): - return c < 0 + def __iter__(self): + return iter(self.grid) - @staticmethod - def to_char(c: int): - if c == BoxUtils.FREE: + def __getitem__(self, index: tuple[int, int]): + return self.grid[*index] + + def __setitem__(self, index: tuple[int, int], value: int): + self.grid[*index] = value + + def is_free(self, row: int, col: int): + return self[row, col] == Grid.FREE + + def is_block(self, row: int, col: int): + return self[row, col] == Grid.BLOCK + + def is_box(self, row: int, col: int): + return (c := self[row, col]) >= 4 and c % 2 == 0 + + def is_open_or_close_box(self, row: int, col: int): + return abs(c := self[row, col]) >= 4 and c % 2 == 1 + + def is_open_box(self, row: int, col: int): + return (c := self[row, col]) >= 4 and c % 2 == 1 + + def is_close_box(self, row: int, col: int): + return self[row, col] < 0 + + def _to_char(self, row: int, col: int): + if self.is_free(row, col): return "." - elif c == BoxUtils.BLOCK: + elif self.is_block(row, col): return "#" - elif BoxUtils.is_box(c): + elif self.is_box(row, col): return "O" - elif BoxUtils.is_open_box(c): + elif self.is_open_box(row, col): return "[" else: return "]" - -class Solver(BaseSolver): - def print_grid(self, name: str, grid: Grid, robot: Robot): - if self.files: - grid_s = [[BoxUtils.to_char(c) for c in row] for row in grid] - grid_s[robot[0]][robot[1]] = "\033[31;1m@\033[00m" - self.files.create( - name, "\n".join("".join(row) for row in grid_s).encode(), True - ) - - def convert_grid(self, grid: Grid, robot: Robot) -> ImageGrid: - import numpy as np - - # grid[robot[0]][robot[1]] = "@" - # return np.array( - # [ - # [ - # # { - # # "#": [255, 255, 255], - # # "@": [255, 0, 0], - # # "O": [0, 255, 0], - # # "[": [0, 0, 255], - # # "]": [0, 0, 255], - # # ".": [0, 0, 0], - # # }[col] - # ord(col) - # for col in row - # ] - # for row in grid - # ], - # dtype=np.uint8, - # ) - arr = np.array(grid) - arr[*robot] = BoxUtils.ROBOT + def as_numpy(self): + arr = self.grid.copy() + arr[*self.robot] = Grid.ROBOT return arr - def step_part1(self, grid: Grid, move: str, robot: Robot): + def as_printable(self): + grid_s = [ + [self._to_char(row, col) for col in range(self.n_columns)] + for row in range(self.n_rows) + ] + grid_s[self.robot[0]][self.robot[1]] = "\033[31;1m@\033[00m" + return "\n".join("".join(row) for row in grid_s) + + def __str__(self): + return self.as_printable() + + +class Solver(BaseSolver): + def save_grid(self, name: str, grid: Grid): + if self.files: + self.files.create(name, grid.as_printable().encode(), True) + + def step_part1(self, grid: Grid, move: str): match move: case "^": d_row, d_col = -1, 0 @@ -119,25 +125,25 @@ class Solver(BaseSolver): case _: assert False - row, col = robot - if grid[row + d_row][col + d_col] == BoxUtils.FREE: - robot = (row + d_row, col + d_col) - elif grid[row + d_row][col + d_col] != BoxUtils.BLOCK: + row, col = grid.robot + if grid.is_free(row + d_row, col + d_col): + grid.robot = (row + d_row, col + d_col) + elif not grid.is_block(row + d_row, col + d_col): n = 1 - while BoxUtils.is_box(grid[row + n * d_row][col + n * d_col]): + while grid.is_box(row + n * d_row, col + n * d_col): n += 1 - if grid[row + n * d_row][col + n * d_col] == BoxUtils.FREE: - robot = (row + d_row, col + d_col) + if grid.is_free(row + n * d_row, col + n * d_col): + grid.robot = (row + d_row, col + d_col) for k in range(2, n + 1): - grid[row + k * d_row][col + k * d_col] = grid[ - row + (k - 1) * d_row - ][col + (k - 1) * d_col] - grid[row + d_row][col + d_col] = BoxUtils.FREE + grid[row + k * d_row, col + k * d_col] = grid[ + row + (k - 1) * d_row, col + (k - 1) * d_col + ] + grid[row + d_row, col + d_col] = Grid.FREE - return grid, robot + return grid - def step_part2(self, grid: Grid, move: str, robot: Robot): + def step_part2(self, grid: Grid, move: str): match move: case "^": d_row, d_col = -1, 0 @@ -150,80 +156,76 @@ class Solver(BaseSolver): case _: assert False - row, col = robot - if grid[row + d_row][col + d_col] == BoxUtils.FREE: - robot = (row + d_row, col + d_col) - elif grid[row + d_row][col + d_col] == BoxUtils.BLOCK: + row, col = grid.robot + if grid.is_free(row + d_row, col + d_col): + grid.robot = (row + d_row, col + d_col) + elif grid.is_block(row + d_row, col + d_col): ... elif move in "<>": n = 1 - while BoxUtils.is_open_or_close_box(grid[row][col + n * d_col]): + while grid.is_open_or_close_box(row, col + n * d_col): n += 1 - if grid[row][col + n * d_col] == BoxUtils.FREE: - robot = (row, col + d_col) + if grid.is_free(row, col + n * d_col): + grid.robot = (row, col + d_col) for k in range(n, 1, -1): - grid[row][col + k * d_col] = grid[row][col + (k - 1) * d_col] - grid[row + d_row][col + d_col] = BoxUtils.FREE + grid[row, col + k * d_col] = grid[row, col + (k - 1) * d_col] + grid[row + d_row, col + d_col] = Grid.FREE elif move in "^v": n = 1 boxes: list[set[int]] = [{col}] while True: to_move = boxes[-1] - if any(grid[row + n * d_row][c] == BoxUtils.BLOCK for c in to_move): + if any(grid.is_block(row + n * d_row, c) for c in to_move): break - if all(grid[row + n * d_row][c] == BoxUtils.FREE for c in to_move): + if all(grid.is_free(row + n * d_row, c) for c in to_move): break as_move: set[int] = set() for c in to_move: - if BoxUtils.is_close_box(grid[row + n * d_row][c]): + if grid.is_close_box(row + n * d_row, c): as_move.update({c - 1, c}) - elif BoxUtils.is_open_box(grid[row + n * d_row][c]): + elif grid.is_open_box(row + n * d_row, c): as_move.update({c, c + 1}) boxes.append(as_move) n += 1 - if all(grid[row + n * d_row][c] == BoxUtils.FREE for c in boxes[-1]): + if all(grid.is_free(row + n * d_row, c) for c in boxes[-1]): for k, to_move in zip(range(n, 1, -1), boxes[-1:0:-1], strict=True): for c in to_move: - grid[row + k * d_row][c] = grid[row + (k - 1) * d_row][c] - grid[row + (k - 1) * d_row][c] = BoxUtils.FREE - robot = (row + d_row, col + d_col) + grid[row + k * d_row, c] = grid[row + (k - 1) * d_row, c] + grid[row + (k - 1) * d_row, c] = Grid.FREE + grid.robot = (row + d_row, col + d_col) - return grid, robot + return grid def run( self, name: str, grid: Grid, - robot: Robot, moves: str, - fn: Callable[ - [Grid, str, Robot], - tuple[Grid, Robot], - ], + fn: Callable[[Grid, str], Grid], generate: bool, ) -> tuple[Grid, list[ImageGrid]]: # initialize images: list[ImageGrid] = [] if generate: - images.append(self.convert_grid(grid, robot)) + images.append(grid.as_numpy()) - self.print_grid(f"initial_grid_{name}.txt", grid, robot) + self.save_grid(f"initial_grid_{name}.txt", grid) for move in self.progress.wrap(moves): self.logger.debug(f"Move '{move}'...") - grid, robot = fn(grid, move, robot) + grid = fn(grid, move) if generate: - images.append(self.convert_grid(grid, robot)) + images.append(grid.as_numpy()) - self.print_grid(f"final_grid_{name}.txt", grid, robot) + self.save_grid(f"final_grid_{name}.txt", grid) return grid, images @@ -245,7 +247,7 @@ class Solver(BaseSolver): grid, images = self.run( "part1", - *BoxUtils.convert(grid_s.splitlines(), False), + Grid(grid_s.splitlines(), False), moves, self.step_part1, self.files is not None, @@ -253,17 +255,17 @@ class Solver(BaseSolver): if self.files: images = np.stack(images, axis=0) images[images >= 2] = 1 + images[images >= 2] // 2 - self.files.image("anim_part1.gif", colors[images]) + self.files.video("anim_part1.webm", colors[images]) yield sum( - 100 * i_row + i_col - for i_row, row in enumerate(grid) - for i_col, col in enumerate(row) - if BoxUtils.is_box(col) + 100 * row + col + for row in range(grid.n_rows) + for col in range(grid.n_columns) + if grid.is_box(row, col) ) grid, images = self.run( "part2", - *BoxUtils.convert(grid_s.splitlines(), True), + Grid(grid_s.splitlines(), True), moves, self.step_part2, self.files is not None, @@ -271,10 +273,10 @@ class Solver(BaseSolver): if self.files: images = np.abs(np.stack(images, axis=0)) images[images >= 2] = 1 + images[images >= 2] // 2 - self.files.image("anim_part2.gif", colors[images]) + self.files.video("anim_part2.webm", colors[images]) yield sum( - 100 * i_row + i_col - for i_row, row in enumerate(grid) - for i_col, col in enumerate(row) - if BoxUtils.is_open_box(col) + 100 * row + col + for row in range(grid.n_rows) + for col in range(grid.n_columns) + if grid.is_open_box(row, col) ) diff --git a/src/holt59/aoc/base.py b/src/holt59/aoc/base.py index 17002f7..f702130 100644 --- a/src/holt59/aoc/base.py +++ b/src/holt59/aoc/base.py @@ -27,20 +27,53 @@ class ProgressHandler(Protocol): class FileHandler: @abstractmethod - def create( - self, filename: str, content: bytes, text: bool = False + def make_path(self, filename: str) -> Path: ... + + @abstractmethod + def notify_created(self, path: Path): ... + + @abstractmethod + def _create( + self, path: Path, content: bytes, text: bool = False ) -> Path | None: ... + def create(self, filename: str, content: bytes, text: bool = False): + path = self._create(self.make_path(filename), content, text) + + if path is not None: + self.notify_created(path) + def image(self, filename: str, image: NDArray[Any]): import imageio.v3 as iio from pygifsicle import optimize # type: ignore - data = iio.imwrite("", image, extension=Path(filename).suffix) # type: ignore - path = self.create(filename, data, False) + path = self.make_path(filename) - assert path is not None + iio.imwrite(path, image) # type: ignore optimize(path, options=["--no-warnings"]) + self.notify_created(path) + + def video(self, filename: str, video: NDArray[Any]): + import cv2 + + path = self.make_path(filename) + fps = 5 + out = cv2.VideoWriter( + path.as_posix(), + cv2.VideoWriter_fourcc(*"vp80"), # type: ignore + fps, + (video.shape[2], video.shape[1]), + True, + ) + + for picture in video: + out.write(picture) + + out.release() + + self.notify_created(path) + class BaseSolver: def __init__( diff --git a/src/holt59/aoc/utils/api/files.py b/src/holt59/aoc/utils/api/files.py index b8f9447..c1571aa 100644 --- a/src/holt59/aoc/utils/api/files.py +++ b/src/holt59/aoc/utils/api/files.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Final @@ -9,9 +10,14 @@ class FileHandlerAPI(FileHandler): def __init__(self, folder: Path): self.folder: Final = folder - def create(self, filename: str, content: bytes, text: bool = False): - self.folder.mkdir(exist_ok=True) - with open(self.folder.joinpath(filename), "wb") as fp: - fp.write(content) - dump_api_message("file", {"filename": filename, "size": len(content)}) + def make_path(self, filename: str) -> Path: return self.folder.joinpath(filename) + + def notify_created(self, path: Path): + dump_api_message("file", {"filename": path.name, "size": os.stat(path).st_size}) + + def _create(self, path: Path, content: bytes, text: bool = False): + self.folder.mkdir(exist_ok=True) + with open(path, "wb") as fp: + fp.write(content) + return path diff --git a/src/holt59/aoc/utils/files.py b/src/holt59/aoc/utils/files.py index 097cf0e..68a2445 100644 --- a/src/holt59/aoc/utils/files.py +++ b/src/holt59/aoc/utils/files.py @@ -10,12 +10,17 @@ class SimpleFileHandler(FileHandler): self.logger: Final = logger self.folder: Final = folder - def create(self, filename: str, content: bytes, text: bool = False): + def make_path(self, filename: str) -> Path: + return self.folder.joinpath(filename) + + def notify_created(self, path: Path): ... + + def _create(self, path: Path, content: bytes, text: bool = False): if text: for line in content.decode("utf-8").splitlines(): self.logger.info(line) else: self.folder.mkdir(exist_ok=True) - with open(self.folder.joinpath(filename), "wb") as fp: + with open(path, "wb") as fp: fp.write(content) - return self.folder.joinpath(filename) + return path