AoC 2021 Day 20: Enhancinator

Source: Trench Map

Part 1: Given a 9->1 cellular automota update function (take the pixel and 8 surrounding pixels as a 9-bit index into the function) and a binary image, apply the function twice and count the number of ‘lit’ pixels. Assume that the canvas is infinite.

That was fun!

First, we want to represent our two data structures. First, the image itself. Because it can be infinite, we’re going to store both pixels we know about (data) and a single value for infinity. We won’t end up with stripes reaching out in this particular case. In most function applications, infinity will stay either on or off. But there is an edge case where it will oscillate. I’ll come back to that when we get there.

Point = Tuple[int, int]

@dataclass(frozen=True)
class InfiniteBitmap:
    '''
    Store an infinitely large bitmap.
    
    data is 'known' values
    infinity is every other point off to infinity
    '''

    data: MutableMapping[Point, bool]
    infinity: bool

    @staticmethod
    def read(file: TextIO) -> 'InfiniteBitmap':
        '''Read an infinite bitmap from file. Assume unset bits (infinity) are False.'''

        logging.info('Reading infinity bitmap')

        data = {
            (x, y): c == '#'
            for y, line in enumerate(file)
            for x, c in enumerate(line.strip())
        }

        mid_x = max(x for x, _ in data) // 2
        mid_y = max(y for _, y in data) // 2

        return InfiniteBitmap({(x - mid_x, y - mid_y): bit for (x, y), bit in data.items()}, False)

    def __repr__(self):
        '''Return a much smaller representation.'''

        return f'InfiniteBinary<{self.infinity}, {len(self)}/{len(self.data)}, {self.bounds()}>'

    def __len__(self) -> int:
        '''Return the number of lit pixels (might be effectively infinite).'''

        if self.infinity:
            return sys.maxsize
        else:
            return sum(
                1 if self[p] else 0
                for p in self.data
            )

    def __getitem__(self, p: Point) -> bool:
        '''Get the value at a given point p (use infinity if the point isn't otherwise known).'''

        return self.data.get(p, self.infinity)

    def bounds(self) -> Tuple[int, int, int, int]:
        '''Return (minimum x, maximum x, minimum y, maximum y)'''

        return (
            min(x for x, _ in self.data) - 5,
            max(x for x, _ in self.data) + 5,
            min(y for _, y in self.data) - 5,
            max(y for _, y in self.data) + 5,
        )

Most of this is just reading the data. I did do one interesting bit in the read function, which was to ‘recenter’ the data. After reading, the range is (0, width), but I’d rather it be (-width/2, width/2) so that the center point is actually in the center.

In addition, we went ahead and put a __len__ function here: the number of lit pixels.

Okay, next up, let’s work on that mapping function. This is the bulk of the problem:

BitIndex = List[bool]

NEIGHBORHOOD = [
    (-1, -1), (0, -1), (1, -1),
    (-1,  0), (0,  0), (1,  0),
    (-1,  1), (0,  1), (1,  1),
]

@dataclass(frozen=True)
class BinaryMapping:
    '''Store a mapping from 9-bit values to 1-bit, loaded from a 512 character string of . (0) and # (1).'''

    map: List[bool]

    @staticmethod
    def read(file: TextIO):
        '''Read a BinaryMapping from an input stream.'''

        logging.info('Reading binary mapping')

        return BinaryMapping([c == '#' for c in file.readline().strip()])

    def __repr__(self):
        '''Return a unique representation of this map.'''

        binary = ''.join('1' if bit else '0' for bit in self.map)
        integer = int(binary, 2)
        bytes = integer.to_bytes(64, 'big')
        b64 = base64.b64encode(bytes).decode()

        return f'BinaryMapping<{b64}>'

    def __getitem__(self, k: Union[int, BitIndex]) -> bool:
        '''Get the value of the BinaryMapping by either integer index of a 9-bit binary BitIndex.'''

        if isinstance(k, int):
            return self.map[k]
        else:
            # TODO: Make this more efficient
            return self.map[int(''.join('1' if bit else '0' for bit in k), 2)]

    def __call__(self, bitmap: InfiniteBitmap) -> InfiniteBitmap:
        '''Apply this mapping to an infinite bitmap, generating a new one with this applied.'''

        logging.info(f'Calling {self} on {bitmap}')

        # Calculate the new infinity

        # If the lowest mapping is set, infinity goes from off to on
        # Likewise on the highest mapping for infinity from on to off
        if not bitmap.infinity and self[0]:
            new_infinity = True
        elif bitmap.infinity and not self[511]:
            new_infinity = False
        else:
            new_infinity = bitmap.infinity

        # Calculate all new points
        new_data = {}

        # Have to calculate all pixels one level out as well
        for x, y in bitmap.data:
            for xd, yd in NEIGHBORHOOD:
                # Don't calculate points more than once per update
                center = (x + xd, y + yd)
                if center in new_data:
                    continue

                neighbors = [
                    bitmap[x + xd + xd2, y + yd + yd2]
                    for xd2, yd2 in NEIGHBORHOOD
                ]

                new_value = self[neighbors]

                # If the value wasn't in the old map and matches the new infinity
                # We don't need to include it (prevent infinity expansion)
                if center in bitmap.data and new_value == new_infinity:
                    continue

                new_data[center] = new_value

        return InfiniteBitmap(new_data, new_infinity)

__getitem__ will handle infinity (so we can always read any pixel, even if it’s not already defined in the image).

__call__ is the core, it allows the function to be called on a InfiniteBitmap and returns the updated bitmap. Let’s go through a few interesting points of it:

  • Updating the infinite pixels: this is the edge case I was talking about earlier. If the first value in the update function (all false) returns true, we need to change infinite to true. Likewise if the last value is false, we need to make infinite false. If both of these are the case (as they are in my input), you’ll get a flicker (see the rendering at the end).

  • Updating the majority of pixels

    • We need to update all pixels in the image plus all pixels adjacent to them, because those might change from off to on.
    • This would lead to infinitely growing memory… except we don’t have to. If the new border pixel matches infinity, not setting it is fine, it will get rescanned on the edge update next time.

And that’s, actually it!

Using the rendering function (we’ll see at the end) and the test data, we can go from this:

To this:

To this:

It matches what we expect! A simple wrapper:

def part1(file: typer.FileText):

    f = BinaryMapping.read(file)
    file.readline()
    bitmap = InfiniteBitmap.read(file)

    final_bitmap = f(f(bitmap))

    print(len(final_bitmap))

And run it:

$ python3 enhancinator.py part1 input.txt
5057
# time 290676167ns / 0.29s

Not bad!

Part 2: Apply the same function 50 times and count the number of lit pixels.

So… this isn’t actually anything harder:

def part2(file: typer.FileText):
    f = BinaryMapping.read(file)
    file.readline()
    bitmap = InfiniteBitmap.read(file)

    for i in range(50):
        bitmap = f(bitmap)

    print(len(bitmap))

Just slower:

$ python3 enhancinator.py part2 input.txt
18502
# time 10267020083ns / 10.27s

A bit slower than I’d like, but I don’t have a huge buildup I can do here. I could certainly use a bit more complicated code to avoid a lot of the Python packing/uppacking and indirection, but that would make the code a lot more complicated to read. So we’ll keep it for now.

Fun with rendering images

Let’s render these images! We’re already mostly there:


@dataclass(frozen=True)
class InfiniteBitmap:
    ...

    def render(self, include_axis: bool = False) -> 'Image':
        '''Render this infinity bitmap as an image.'''

        from PIL import Image  # type: ignore

        min_x, max_x, min_y, max_y = self.bounds()
        width = max_x - min_x + 1
        height = max_y - min_y + 1

        WHITE = (255, 255, 255)
        BLACK = (0, 0, 0)
        GRAY = (127, 127, 127)

        image = Image.new('RGB', (width, height), WHITE)
        pixels = image.load()

        for x in range(width):
            for y in range(height):
                ix = x + min_x
                iy = y + min_y

                if self[ix, iy]:
                    pixels[x, y] = BLACK
                elif include_axis and (ix == 0 or iy == 0):
                    pixels[x, y] = GRAY

        return image

And a wrapper to render with some nice options:

@app.command()
def render(file: typer.FileText, size: str, target: pathlib.Path, generations: int):

    from PIL import Image  # type: ignore

    f = BinaryMapping.read(file)
    file.readline()
    bitmap = InfiniteBitmap.read(file)

    try:
        width, height = [int(v) for v in size.split('x')]
    except:
        typer.echo('Unable to parse size, expecting a value like 400x400', err=True)
        raise typer.Exit(1)

    base_image = bitmap.render(True).resize((width, height), Image.NEAREST)

    rest_images = []
    for i in range(1, generations + 1):
        bitmap = f(bitmap)
        rest_images.append(bitmap.render(True).resize((width, height), Image.NEAREST))

    if str(target).lower().endswith('gif'):
        base_image.save(target, save_all=True, append_images=rest_images)
    else:
        for i, image in enumerate([base_image] + rest_images):
            image.save(str(target).format(i=i))

For the test input:

$ python3 enhancinator.py render test.txt 400x400 'aoc2021-20-test.gif' 100

That’s pretty cool. What about my own input?

$ python3 enhancinator.py render input.txt 400x400 'aoc2021-20-input.gif' 100

Huh. Other than the flashing, that’s not actually that cool. Eating away at the corner is interesting enough. I suppose it has to be generated for different users, so there’s only so much you can do with it. So it goes.

Now I wonder how long it takes to completely clear…

Finding emptiness

def find_emptiness(file: typer.FileText):

    f = BinaryMapping.read(file)
    file.readline()
    bitmap = InfiniteBitmap.read(file)

    generation = 0
    while len(bitmap):
        bitmap = f(bitmap)
        generation += 1

    print(generation)

How long?

It’s still running…