From db1ff5bc12c2c75e997a50d47a50ece6ac6a3100 Mon Sep 17 00:00:00 2001 From: Peter Gagliardi Date: Thu, 5 Jun 2025 10:47:39 -0400 Subject: [PATCH 1/4] WIP - get classic tangler working --- example_input/config_classic.json | 19 ++++++ src/raster_types.py | 6 ++ src/tangles/mask.py | 19 +++--- src/tangles/palette.py | 19 ++++-- src/tangles/renderer.py | 3 +- src/tangles/renderers/classic.py | 94 +++++++++++++++++++++++++++++ src/tangles/renderers/strokefill.py | 25 +++++--- src/tangles/tangle_config.py | 26 +++++++- 8 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 example_input/config_classic.json create mode 100644 src/raster_types.py create mode 100644 src/tangles/renderers/classic.py diff --git a/example_input/config_classic.json b/example_input/config_classic.json new file mode 100644 index 0000000..4434e5b --- /dev/null +++ b/example_input/config_classic.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0", + "tangler": { + "algorithm": "classic", + "masks": [ + "coarse", + "medium", + "fine" + ] + }, + "renderer": { + "algorithm": "classic", + "palette": "386641-6a994e-a7c957-f2e8cf-bc4749" + }, + "dimensions": [ + 50, + 70 + ] +} diff --git a/src/raster_types.py b/src/raster_types.py new file mode 100644 index 0000000..21a11a3 --- /dev/null +++ b/src/raster_types.py @@ -0,0 +1,6 @@ +import numpy +from numpy.typing import NDArray + +IndexMask = NDArray[numpy.uint8] +AddressRaster = NDArray[numpy.uint32] +TangleImage = NDArray[numpy.uint8] diff --git a/src/tangles/mask.py b/src/tangles/mask.py index e9019ed..e6ca039 100644 --- a/src/tangles/mask.py +++ b/src/tangles/mask.py @@ -1,6 +1,9 @@ from typing import Self, overload import numpy +from numpy.typing import NDArray + +from raster_types import IndexMask def get_indices_present(img: numpy.ndarray) -> numpy.ndarray: @@ -11,10 +14,10 @@ def get_indices_present(img: numpy.ndarray) -> numpy.ndarray: class Mask: - img: numpy.ndarray + img: IndexMask index_count: int - def __init__(self, img: numpy.ndarray): + def __init__(self, img: IndexMask): if len(img.shape) != 2: raise ValueError("img must have one channel") @@ -45,13 +48,13 @@ def __eq__(self, value: object) -> bool: @overload def __getitem__(self, key: tuple[int, int]) -> int: - raise NotImplementedError + pass @overload - def __getitem__(self, key: numpy.ndarray) -> numpy.ndarray: - raise NotImplementedError + def __getitem__(self, key: NDArray[numpy.bool]) -> IndexMask: + pass - def __getitem__(self, key): + def __getitem__(self, key: tuple[int, int] | NDArray[numpy.bool]) -> int | IndexMask: rows, cols = self.img.shape if isinstance(key, tuple): # key is (row, col) as a tuple @@ -63,7 +66,7 @@ def __getitem__(self, key): return resized[key] - def resize(self, desired_shape: tuple[int, int]) -> numpy.ndarray: + def resize(self, desired_shape: tuple[int, int]) -> IndexMask: """ Pad or crop the underlying image out to the desired shape, wrapping if it needs to be larger than the original image @@ -82,7 +85,7 @@ def resize(self, desired_shape: tuple[int, int]) -> numpy.ndarray: return self.img[:result_rows, :result_cols] @classmethod - def from_rgb(cls, img: numpy.ndarray) -> Self: + def from_rgb(cls, img: NDArray[numpy.uint8]) -> Self: # Get the top bits of each channel, though they are shifted # into the least-significant bit # RRRRRRRR GGGGGGGG BBBBBBBB diff --git a/src/tangles/palette.py b/src/tangles/palette.py index 563b66c..7888cad 100644 --- a/src/tangles/palette.py +++ b/src/tangles/palette.py @@ -1,10 +1,13 @@ -import numpy + import re +import numpy +from numpy.typing import NDArray + DASH_SEPARATED_HEX_CODES = re.compile(r"^[0-9A-Fa-f]{6}(-[0-9A-Fa-f]{6})*$") -def int_to_rgb(color_int: int) -> numpy.ndarray: +def int_to_rgb(color_int: int) -> NDArray[numpy.uint8]: """ Convert an integer hex code to a color """ @@ -14,7 +17,7 @@ def int_to_rgb(color_int: int) -> numpy.ndarray: return numpy.array([red, green, blue], dtype=numpy.uint8) -def read_palette(palette_str: str) -> numpy.ndarray: +def read_palette(palette_str: str) -> NDArray[numpy.uint8]: """ Read a color palette from a string of dash-separated hex codes """ @@ -32,16 +35,22 @@ class Palette: This is implemented as a numpy array of RGB colors with an index that wraps. """ + _palette: NDArray[numpy.uint8] def __init__(self, palette_str: str): self._palette = read_palette(palette_str) - def __len__(self): + def __len__(self) -> int: return len(self._palette) - def __getitem__(self, index: int | numpy.ndarray) -> numpy.ndarray: + def __getitem__(self, index: int | NDArray[numpy.uint32]) -> NDArray[numpy.uint8]: """ Use the subscript operator to lookup the color. Out-of-bounds indices wrap in both directions. + + If a single index is given, the result will be a single RGB color + as a u8 array + If an array of indices is given, the result will have shape + (...shape, 3) as each entry will contain an RGB color """ return self._palette[index % len(self._palette)] diff --git a/src/tangles/renderer.py b/src/tangles/renderer.py index 249bafb..1cb988b 100644 --- a/src/tangles/renderer.py +++ b/src/tangles/renderer.py @@ -2,6 +2,7 @@ from typing import Protocol import numpy +from numpy.typing import NDArray from tangles.tangletree import TangleTree @@ -12,5 +13,5 @@ class TangleRenderer(Protocol): and generates an image """ @abstractmethod - def render(self, tree: TangleTree) -> numpy.ndarray: + def render(self, tree: TangleTree) -> NDArray[numpy.uint8]: raise NotImplementedError diff --git a/src/tangles/renderers/classic.py b/src/tangles/renderers/classic.py new file mode 100644 index 0000000..aae8ff4 --- /dev/null +++ b/src/tangles/renderers/classic.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +import numpy +from numpy.typing import NDArray + +from tangles.address import BITS_PER_LEVEL +from tangles.mask import Mask +from tangles.palette import Palette +from raster_types import AddressRaster, TangleImage +from tangles.tangletree import TangleTree + + +@dataclass +class ClassicMaskPalettes: + palette_mask: Mask + palettes: list[Palette] + + +class ClassicLayers: + shape: tuple[int, int] + addresses: AddressRaster + + def __init__(self, shape: tuple[int, int]): + self.shape = shape + self.addresses = numpy.zeros(self.shape, dtype=numpy.uint32) + + def subdivide(self, address: int, mask: Mask): + selected_pixels: NDArray[numpy.bool] = self.addresses == address + self.addresses[selected_pixels] = ( + address << BITS_PER_LEVEL) | mask[selected_pixels].astype(numpy.uint32) + + def color_pixels(self, address: int, index: int): + selected_pixels: NDArray[numpy.bool] = self.addresses == address + self.addresses[selected_pixels] = index + + def composite_basic(self, palette: Palette) -> TangleImage: + rows, cols = self.shape + output_shape = (rows, cols, 3) + # img = numpy.zeros(output_shape, dtype=numpy.uint8) + + return palette[self.addresses] + + def composite_mask(self, palettes: ClassicMaskPalettes) -> TangleImage: + raise NotImplementedError + + def composite(self, palettes: Palette | ClassicMaskPalettes) -> TangleImage: + match palettes: + case ClassicMaskPalettes(): + return self.composite_mask(palettes) + case _: + return self.composite_basic(palettes) + + +class ClassicRenderer: + shape: tuple[int, int] + # Color palette(s) to use + palettes: Palette | ClassicMaskPalettes + + def __init__(self, shape: tuple[int, int], palettes: Palette | ClassicMaskPalettes): + self.shape = shape + self.palettes = palettes + + def render_recursive(self, leaf_indices: dict[int, int], layers: ClassicLayers, tree: TangleTree): + # leaf - mark corresponding pixels with the appropriate leaf number + if not tree.mask: + leaf_address = tree.address.bits + leaf_index = leaf_indices[leaf_address] + layers.color_pixels(leaf_address, leaf_index) + return + + # interior node: + # replace parent address with its children + # addr -> addr0, addr1, addr2, ... + layers.subdivide(tree.address.bits, tree.mask) + + # unlike StrokeFillRenderer, we don't color any pixels at interior + # nodes + + # subdivide over all children + for child in tree.children: + self.render_recursive(leaf_indices, layers, child) + + def render(self, tree: TangleTree) -> TangleImage: + rows, cols = self.shape + output_shape = (rows, cols, 3) + + if tree.is_empty: + return numpy.zeros(output_shape, dtype=numpy.uint8) + + leaf_indices = tree.label_leaves(skip_first_child=False) + + layers = ClassicLayers(self.shape) + self.render_recursive(leaf_indices, layers, tree) + + return layers.composite(self.palettes) diff --git a/src/tangles/renderers/strokefill.py b/src/tangles/renderers/strokefill.py index 965e15c..5f8fe5c 100644 --- a/src/tangles/renderers/strokefill.py +++ b/src/tangles/renderers/strokefill.py @@ -1,5 +1,10 @@ from dataclasses import dataclass + import numpy +from numpy.typing import NDArray + + +from raster_types import AddressRaster, TangleImage from tangles.address import BITS_PER_LEVEL, Address from tangles.mask import Mask from tangles.palette import Palette @@ -33,7 +38,13 @@ def __init__(self, palette_mask: Mask, stroke_palette: Palette, fill_palettes: l class StrokeFillLayers: - def __init__(self, shape): + shape: tuple[int, int] + addresses: AddressRaster + fill_indices: AddressRaster + stroke_indices: AddressRaster + is_stroke: NDArray[numpy.bool] + + def __init__(self, shape: tuple[int, int]): self.shape = shape self.addresses = numpy.zeros(self.shape, dtype=numpy.uint32) self.fill_indices = numpy.zeros(self.shape, dtype=numpy.uint32) @@ -41,7 +52,7 @@ def __init__(self, shape): self.is_stroke = numpy.zeros(self.shape, dtype=numpy.bool) def subdivide(self, address: int, mask: Mask): - selected_pixels = self.addresses == address + selected_pixels: NDArray[numpy.bool] = self.addresses == address self.addresses[selected_pixels] = (address << BITS_PER_LEVEL) | mask[selected_pixels].astype( numpy.uint32) @@ -55,7 +66,7 @@ def add_stroke(self, address: Address): self.stroke_indices[selected_pixels] = address.level self.is_stroke[selected_pixels] = True - def composite_stroke_fill(self, palettes: StrokeFillPalettes) -> numpy.ndarray: + def composite_stroke_fill(self, palettes: StrokeFillPalettes) -> TangleImage: rows, cols = self.shape output_shape = (rows, cols, 3) img = numpy.zeros(output_shape, dtype=numpy.uint8) @@ -75,7 +86,7 @@ def composite_stroke_fill(self, palettes: StrokeFillPalettes) -> numpy.ndarray: img[stroke_pixels] = stroke_colors return img - def composite_palette_mask(self, palettes: PaletteMaskPalettes) -> numpy.ndarray: + def composite_palette_mask(self, palettes: PaletteMaskPalettes) -> NDArray[numpy.uint8]: rows, cols = self.shape output_shape = (rows, cols, 3) img = numpy.zeros(output_shape, numpy.uint8) @@ -101,7 +112,7 @@ def composite_palette_mask(self, palettes: PaletteMaskPalettes) -> numpy.ndarray return img - def composite(self, palettes: StrokeFillPalettes | PaletteMaskPalettes) -> numpy.ndarray: + def composite(self, palettes: StrokeFillPalettes | PaletteMaskPalettes) -> NDArray[numpy.uint8]: match palettes: case StrokeFillPalettes(): return self.composite_stroke_fill(palettes) @@ -128,7 +139,7 @@ def render_recursive(self, leaf_indices: dict[int, int], layers: StrokeFillLayer # interior node: # replace parent address with its children - # addr -> addr0, addr1, addr2 + # addr -> addr0, addr1, addr2... layers.subdivide(tree.address.bits, tree.mask) # mark the stroke pixels @@ -139,7 +150,7 @@ def render_recursive(self, leaf_indices: dict[int, int], layers: StrokeFillLayer for child in tree.children[1:]: self.render_recursive(leaf_indices, layers, child) - def render(self, tree: TangleTree) -> numpy.ndarray: + def render(self, tree: TangleTree) -> NDArray[numpy.uint8]: rows, cols = self.shape output_shape = (rows, cols, 3) diff --git a/src/tangles/tangle_config.py b/src/tangles/tangle_config.py index 5acd97d..65459c0 100644 --- a/src/tangles/tangle_config.py +++ b/src/tangles/tangle_config.py @@ -6,16 +6,20 @@ from dataclasses import dataclass -from typing import Any, Optional +from typing import Any, Optional, TypeAlias from tangles.image_reader import ImageReader from tangles.mask import Mask from tangles.palette import Palette from tangles.renderer import TangleRenderer +from tangles.renderers.classic import ClassicMaskPalettes, ClassicRenderer from tangles.renderers.lineart import LineartRenderer from tangles.renderers.strokefill import PaletteMaskPalettes, StrokeFillPalettes, StrokeFillRenderer from tangles.tanglers.classic import ClassicTangler +JSON: TypeAlias = ( + dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None) + def expect_json_dict(label: str, json: dict) -> dict[str, Any]: value = json.get(label) @@ -135,6 +139,19 @@ def parse_palette_mask_renderer(json: dict, output_shape: tuple[int, int], img_r return StrokeFillRenderer(output_shape, palettes) +def parse_classic_renderer(json: dict, output_shape: tuple[int, int]) -> ClassicRenderer: + palette = parse_palette("palette", json.get("palette")) + return ClassicRenderer(output_shape, palette) + + +def parse_classic_mask_renderer(json: dict, output_shape: tuple[int, int], img_reader: ImageReader) -> ClassicRenderer: + palette_mask = parse_palette_mask(json.get("palette_mask"), img_reader) + palettes = parse_palettes("palettes", json.get("palettes")) + + mask_palettes = ClassicMaskPalettes(palette_mask, palettes) + return ClassicRenderer(output_shape, mask_palettes) + + def parse_renderer(json: dict, output_shape: tuple[int, int], img_reader: ImageReader) -> TangleRenderer: match json.get("algorithm"): case "line-art": @@ -143,8 +160,13 @@ def parse_renderer(json: dict, output_shape: tuple[int, int], img_reader: ImageR return parse_stroke_fill(json, output_shape) case "palette-mask": return parse_palette_mask_renderer(json, output_shape, img_reader) + case "classic": + return parse_classic_renderer(json, output_shape) + case "classic-mask": + return parse_classic_mask_renderer(json, output_shape, img_reader) case _: - raise ValueError("algorithm must be one of {line-art,stroke-fill}") + raise ValueError( + "algorithm must be one of {line-art,stroke-fill,palette-mask,classic,classic-mask}") def parse_config(json: dict, img_reader: ImageReader) -> TangleConfig: From a2cb8365d7a1bd7e93bea994e5e20a6bb85ca3d2 Mon Sep 17 00:00:00 2001 From: Peter Gagliardi Date: Thu, 5 Jun 2025 10:55:48 -0400 Subject: [PATCH 2/4] Implement classic version of palette mask --- example_input/config_classic_mask.json | 23 ++++++++++++++++++ src/tangles/renderers/classic.py | 32 ++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 example_input/config_classic_mask.json diff --git a/example_input/config_classic_mask.json b/example_input/config_classic_mask.json new file mode 100644 index 0000000..6b8f053 --- /dev/null +++ b/example_input/config_classic_mask.json @@ -0,0 +1,23 @@ +{ + "version": "1.0.0", + "tangler": { + "algorithm": "classic", + "masks": [ + "coarse", + "medium", + "fine" + ] + }, + "renderer": { + "algorithm": "classic-mask", + "palette_mask": "palette-mask.png", + "palettes": [ + "40f99b-61707d-9d69a3-f5fbef-e85d75", + "aa8f66-ed9b40-ffeedb-61c9a8-ba3b46" + ] + }, + "dimensions": [ + 50, + 70 + ] +} diff --git a/src/tangles/renderers/classic.py b/src/tangles/renderers/classic.py index aae8ff4..73c2a82 100644 --- a/src/tangles/renderers/classic.py +++ b/src/tangles/renderers/classic.py @@ -9,11 +9,21 @@ from tangles.tangletree import TangleTree -@dataclass class ClassicMaskPalettes: palette_mask: Mask palettes: list[Palette] + def __init__(self, palette_mask: Mask, palettes: list[Palette]): + index_count = palette_mask.index_count + palette_count = len(palettes) + + if index_count != palette_count: + raise ValueError( + f"there must be a palette for every color in palette_mask. Expected {index_count}, got {palette_count}") + + self.palette_mask = palette_mask + self.palettes = palettes + class ClassicLayers: shape: tuple[int, int] @@ -33,14 +43,26 @@ def color_pixels(self, address: int, index: int): self.addresses[selected_pixels] = index def composite_basic(self, palette: Palette) -> TangleImage: + return palette[self.addresses] + + def composite_mask(self, palettes: ClassicMaskPalettes) -> TangleImage: rows, cols = self.shape output_shape = (rows, cols, 3) - # img = numpy.zeros(output_shape, dtype=numpy.uint8) + img = numpy.zeros(output_shape, numpy.uint8) - return palette[self.addresses] + palette_mask = palettes.palette_mask + padded_mask = palette_mask.resize(self.shape) - def composite_mask(self, palettes: ClassicMaskPalettes) -> TangleImage: - raise NotImplementedError + for index in range(palette_mask.index_count): + palette = palettes.palettes[index] + + selected_pixels: NDArray[numpy.bool] = padded_mask == index + addresses = self.addresses[selected_pixels] + colors = palette[addresses] + + img[selected_pixels] = colors + + return img def composite(self, palettes: Palette | ClassicMaskPalettes) -> TangleImage: match palettes: From 989fe317e70942624f69e995e368d41a7e981e86 Mon Sep 17 00:00:00 2001 From: Peter Gagliardi Date: Thu, 5 Jun 2025 19:47:34 -0400 Subject: [PATCH 3/4] WIP - tangle grammar almost working --- src/tangles/tangle_config.py | 40 ++++++++++++++++++++++++++-- src/tangles/tanglers/grammar.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/tangles/tanglers/grammar.py diff --git a/src/tangles/tangle_config.py b/src/tangles/tangle_config.py index 65459c0..4e73e16 100644 --- a/src/tangles/tangle_config.py +++ b/src/tangles/tangle_config.py @@ -16,6 +16,7 @@ from tangles.renderers.lineart import LineartRenderer from tangles.renderers.strokefill import PaletteMaskPalettes, StrokeFillPalettes, StrokeFillRenderer from tangles.tanglers.classic import ClassicTangler +from tangles.tanglers.grammar import GrammarRule, GrammarTangler JSON: TypeAlias = ( dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None) @@ -77,7 +78,7 @@ def parse_shape(dimensions: Optional[list[int]]) -> tuple[int, int]: return (height, width) -ALL_TANGLER_ALGORITHMS = {"classic"} +ALL_TANGLER_ALGORITHMS = {"classic", "grammar"} def parse_mask_dir(dir: str, img_reader: ImageReader) -> list[Mask]: @@ -107,12 +108,47 @@ def parse_classic_tangler(json: dict, img_reader: ImageReader) -> ClassicTangler return ClassicTangler(masks_by_level, skip_first_child=True) +def parse_grammar_rule(json: JSON, img_reader: ImageReader) -> GrammarRule: + mask_pack = json.get("mask_pack") + if mask_pack is not None: + masks = parse_mask_dir(mask_pack, img_reader) + else: + img = img_reader.read_image(json.get("mask")) + masks = [Mask.from_rgb(img)] + + children_ids = json.get("children") + if children_ids is None: + children = [] + elif isinstance(children_ids, str): + children = [children_ids] + elif isinstance(children_ids, list): + children = children_ids + + return GrammarRule(masks, children) + + +def parse_grammar_tangler(json: dict, img_reader: ImageReader) -> GrammarTangler: + start_rule = json.get("start") + + rules: dict[str, JSON] = json.get("rules") + + parsed_rules = { + id: parse_grammar_rule(rule, img_reader) + for id, rule in rules.items() + } + + return GrammarTangler(start_rule, parsed_rules) + + def parse_tangler(json: dict, img_reader: ImageReader) -> ClassicTangler: match json.get("algorithm"): case "classic": return parse_classic_tangler(json, img_reader) + case "grammar": + return parse_grammar_tangler(json, img_reader) case _: - raise ValueError("algorithm must be one of {classic}") + raise ValueError( + f"algorithm must be one of {ALL_TANGLER_ALGORITHMS}") def parse_stroke_fill(json: dict, output_shape: tuple[int, int]) -> StrokeFillRenderer: diff --git a/src/tangles/tanglers/grammar.py b/src/tangles/tanglers/grammar.py new file mode 100644 index 0000000..929dec9 --- /dev/null +++ b/src/tangles/tanglers/grammar.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Generator +from tangles.address import Address +from tangles.mask import Mask +from tangles.rng import Rng +from tangles.tanglers.classic import SplitCommand +from tangles.tangletree import TangleTree + + +@dataclass +class GrammarRule: + masks: list[Mask] + children: list[str] + + +class GrammarTangler: + start_rule: str + rules: dict[str, GrammarRule] + + def __init__(self, start_rule: str, rules: dict[str, GrammarRule]): + self.start_rule = start_rule + self.rules = rules + + def _generate_commands(self, rng: Rng, parent_addr: Address, rule_id: str) -> Generator[SplitCommand, None, None]: + rule = self.rules[rule_id] + + mask = rng.choice(rule.masks) + print(parent_addr, len(rule.masks)) + yield SplitCommand(parent_addr, mask) + + for i, child_rule in enumerate(rule.children): + child_addr = parent_addr.get_child(i) + yield from self._generate_commands(rng, child_addr, child_rule) + + def tangle(self, rng: Rng) -> TangleTree: + tree = TangleTree() + + for split_command in self._generate_commands(rng, Address.root(), self.start_rule): + tree.split(split_command.address, split_command.mask) + + return tree + + def tangle_stepwise(self, rng: Rng) -> Generator[TangleTree, None, None]: + tree = TangleTree() + for split_command in self._generate_commands(rng, Address.root(), self.start_rule): + tree.split(split_command.address, split_command.mask) + yield tree From 996a0c4176a953b827b29a38e49970bd84c4cf27 Mon Sep 17 00:00:00 2001 From: Peter Gagliardi Date: Sat, 7 Jun 2025 11:31:42 -0400 Subject: [PATCH 4/4] Fix handling of random nodes --- src/tangles/tanglers/grammar.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tangles/tanglers/grammar.py b/src/tangles/tanglers/grammar.py index 929dec9..0d5d09b 100644 --- a/src/tangles/tanglers/grammar.py +++ b/src/tangles/tanglers/grammar.py @@ -25,10 +25,20 @@ def _generate_commands(self, rng: Rng, parent_addr: Address, rule_id: str) -> Ge rule = self.rules[rule_id] mask = rng.choice(rule.masks) - print(parent_addr, len(rule.masks)) yield SplitCommand(parent_addr, mask) - for i, child_rule in enumerate(rule.children): + children_count = mask.index_count + if not rule.children: + children = [] + elif len(rule.children) == 1: + children = [rule.children[0] for _ in range(children_count)] + elif len(rule.children) == children_count: + children = rule.children + else: + raise Exception( + f"expected {children_count} children, got {len(rule.children)}") + + for i, child_rule in enumerate(children): child_addr = parent_addr.get_child(i) yield from self._generate_commands(rng, child_addr, child_rule)