Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions example_input/config_classic.json
Original file line number Diff line number Diff line change
@@ -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
]
}
23 changes: 23 additions & 0 deletions example_input/config_classic_mask.json
Original file line number Diff line number Diff line change
@@ -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
]
}
6 changes: 6 additions & 0 deletions src/raster_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import numpy
from numpy.typing import NDArray

IndexMask = NDArray[numpy.uint8]
AddressRaster = NDArray[numpy.uint32]
TangleImage = NDArray[numpy.uint8]
19 changes: 11 additions & 8 deletions src/tangles/mask.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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")

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/tangles/palette.py
Original file line number Diff line number Diff line change
@@ -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
"""
Expand All @@ -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
"""
Expand All @@ -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)]
3 changes: 2 additions & 1 deletion src/tangles/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Protocol

import numpy
from numpy.typing import NDArray

from tangles.tangletree import TangleTree

Expand All @@ -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
116 changes: 116 additions & 0 deletions src/tangles/renderers/classic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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


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]
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:
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, numpy.uint8)

palette_mask = palettes.palette_mask
padded_mask = palette_mask.resize(self.shape)

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:
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)
25 changes: 18 additions & 7 deletions src/tangles/renderers/strokefill.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,15 +38,21 @@ 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)
self.stroke_indices = numpy.zeros(self.shape, dtype=numpy.uint32)
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)

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)

Expand Down
Loading