Dazbo's Advent of Code solutions, written in Python
We’re told we have an image from our scanners, but it needs enhancing. Our input contains two different things:
The input looks something like this:
..#.#..#####.#.#.#.###.##.....###.##.#..###.####..### // .#.#.#...##..#.#..###..#####........#..####......#..#
#..#.
#....
##..#
..#..
..###
The first line is actually 512 chars is length, but I’ve trimmed it above (indicated using //), for readability. Then we have a blank line, followed by the image we need to enhance, whihc is itself split over multiple lines.  The image is a 2D grid of light pixels (indicated by #) and dark pixels (indicated by .).
The image enhancement algorithm does the following:
.=0 and #=1. (The 3x3 grid is read from top left to bottom right, across then down.) Thus, each pixel maps to a 9-bit value.0b000000000 = 0 in decimal. Thus, this would be a lookup to the first character in the algorithm.0b111111111 = 512 in decimal. Thus, this would be a lookup of the last character in the algorithm. (Since it’s a 512-character str.)Apply the image enhancement algorithm twice, and then determine how many pixels are lit.
from __future__ import annotations
import logging
from pathlib import Path
import time
from typing import NamedTuple
logging.basicConfig(format="%(asctime)s.%(msecs)03d:%(levelname)s:%(name)s:\t%(message)s", 
                    datefmt='%Y-%m-%d %H:%M:%S')
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
SCRIPT_DIR = Path(__file__).parent
# INPUT_FILE = Path(SCRIPT_DIR, "input/input.txt")
INPUT_FILE = Path(SCRIPT_DIR, "input/sample_input.txt")
Let’s create a Point class.  It’s very similar to what we’ve done before. The neighbours() method returns all 9 Points that are centered around this point, including this point itself.
class Point(NamedTuple):
    """ Point class, which knows how to return a 3x3 grid of all Points centred on itself. """    
    
    DELTAS = [(dx,dy) for dy in range(-1, 2) for dx in range(-1, 2)] 
   
    x: int
    y: int
    
    def neighbours(self) -> list[Point]:
        """ Return 3x3 grid of all points centered on itself """
        return [Point(self.x+dx, self.y+dy) for dx,dy in Point.DELTAS] 
Here’s the game plan:
ImageArray class, passing in the 2D image, and the enhancement algorithm. Our ImageArray __init__() also takes a canvas_char, but we’ll come back to that later._process_img_str(), to convert the str image input to a set, storing only lit pixels.set, i.e. the minimum and maximum x and y values stored in the set.render_as_str() method, so that we see a text representation of the ImageArray object. This simply converts the stored set back to a str representation, by converting all lit pixels (i.e. those in the set) to #, and converting all other pixels (within the bounds) to ..property that returns this, by counting how many pixels are in the set.class ImageArray():
    """ Stores array of pixels (points) in a set. 
    Knows how many pixels are lit.  Is able to create a new ImageArray based on rules. """
    LIGHT = "#" # 1
    DARK = "."  # 0
    BIN_MAP = { DARK: '0', LIGHT: '1'}
    
    def __init__(self, image_data: str|set, img_enhancement_map: str, canvas_char='.') -> None:
        """ Create a new ImageArray, containing a set of lit pixels.
        Args:
            image_data (str|set): Str representation or set.
            img_enhancement_map (str): Map used for enhancing the image.
            canvas_char (str, optional): Typically DARK, but can be lit depending on enhancement map.
        """
        self._img_enhancement_map = img_enhancement_map
        
        if isinstance(image_data, str):
            self._pixels = self._process_img_str(image_data)    # convert to set
        else:
            assert isinstance(image_data, set)
            self._pixels = image_data
        
        # bounds of set, based on min and max coords in the set
        self._min_x = min(pixel.x for pixel in self._pixels)
        self._max_x = max(pixel.x for pixel in self._pixels)
        self._min_y = min(pixel.y for pixel in self._pixels)
        self._max_y = max(pixel.y for pixel in self._pixels)
        
        # The background canvas char can be changed, depending on first and last chars of the enhancement map
        self._canvas_char = canvas_char 
        
    def _process_img_str(self, image_data: str) -> set[Point]:
        """ Take a str of image data and convert to a set. Only stores points that are lit. """
        pixels = set()
        
        for y, line in enumerate(image_data.splitlines()):
            for x, char in enumerate(line):
                if char == ImageArray.LIGHT:    # only store lit pixels
                    pixels.add(Point(x, y))
        
        return pixels
    
    def render_as_str(self) -> str:
        """ Generate str representation """
        lines = []    
        for y in range(self._min_y, self._max_y+1):
            line = ""
            for x in range(self._min_x, self._max_x+1):                 
                char = ImageArray.LIGHT if Point(x,y) in self._pixels else ImageArray.DARK
                line += char
            lines.append(line)
            
        return "\n".join(lines)
       
    @property
    def lit_count(self) -> int:
        """ Return count of lit pixels """
        return len(self._pixels)
    def _outside_bounds(self, point: Point) -> bool:
        """ Determine if the specified point is within the existing bounds of the image. """
        return (point.x < self._min_x or point.x > self._max_x or
                point.y < self._min_y or point.y > self._max_y)
        
    def __repr__(self) -> str:
        return self.render_as_str()
We’ve been asked to enhance twice. The actual enhancement algorithm is implemented by the ImageArray's enhance() method. It works like this:
set to store lit pixels after enhancement.enhance() method looks looks at ranges in the x and y dimenions that are extended by 1 at each end._image_enhancement_index() for each Point. This method:
    Point.str.str to a decimal value, and returns it, using int(bin_str, 2).  For example, int("000001010", 2) returns 10. This is our lookup index.if self._canvas_char == ImageArray.LIGHT: part is all about?
We’ll come back to that!Point) is light or dark. If it’s light, we store in the new set.ImageArray object, using the new set of lit pixels as the new image.ImageArray __init__() method can accept an input image in two formats:
    str, since this is what we’re provided with in the input data.set, since we’re only storing lit pixels, and this is much more efficient. Since we want to create new ImageArray instances from sets of lit pixels, it’s much more efficient to allow this directly, rather than converting to and from str objects each time.    def enhance(self) -> ImageArray:
        """ Process all squares simultaneously, i.e. based on current state of all pixels.
        Returns: New ImageArray, which will 1px bigger in all directions. """
        new_pixels = set()
        # Process using rules, with a 1px border around existing bounds
        for y in range(self._min_y-1, self._max_y+2):
            for x in range(self._min_x-1, self._max_x+2): 
                pnt = Point(x,y)       
                enhancement_i = self._image_enhancement_index(pnt) # get enhancement index for this point
                char = self._img_enhancement_map[enhancement_i] # determine type of pixel
                if char == ImageArray.LIGHT:
                    new_pixels.add(pnt)
        
        # Update the char that should be used for the infinite canvas next time.          
        next_canvas_char = self._img_enhancement_map[ImageArray._surrounded_by_index(self._canvas_char)]   
        return ImageArray(new_pixels, self._img_enhancement_map, canvas_char=next_canvas_char)
    
    @classmethod
    def _surrounded_by_index(cls, char: str) -> int:
        """ Get the mapping index for any char surrounded by . or # 
        I.e. where the 3x3 grid is all '.' (so int=0) or all '#' (so int=511). """
        assert char in (ImageArray.DARK, ImageArray.LIGHT), "Can only be surrounded by . or #"
        return ImageArray.convert_to_dec(9*ImageArray.BIN_MAP[char])
    
    def _image_enhancement_index(self, point: Point) -> int:
        """ Determine the decimal value of the 9-bit representation of this point.
        The 9-bit representation of the point is based on the 3x3 grid of pixels with this point at the centre. 
        Pixel lit (#) = 1, else 0.
        E.g. if only BR it lit, then the binary repr is 000000001.  If TL is lit, then 100000000.
        If the infinite canvas should be lit, 
        then treat any pixels outside of the current boundary as a lit pixel. """
        
        nine_box_bin = ""
        for nine_box_point in point.neighbours():   # process pixel by pixel
            if nine_box_point in self._pixels:  # If this is lit
                nine_box_bin += ImageArray.BIN_MAP[ImageArray.LIGHT]
            elif (self._outside_bounds(nine_box_point)): # in the infinite canvas area
                if self._canvas_char == ImageArray.LIGHT:
                    nine_box_bin += ImageArray.BIN_MAP[ImageArray.LIGHT]
                else:
                    nine_box_bin += ImageArray.BIN_MAP[ImageArray.DARK]
            else:   # dark pixel, and within bounds
                nine_box_bin += ImageArray.BIN_MAP[ImageArray.DARK]
        
        return ImageArray.convert_to_dec(nine_box_bin)
    
    @staticmethod
    def convert_to_dec(input_str: str) -> int:
        """ Convert bin str (e.g. '111110101') to int value """
        assert len(input_str) == 9, "Valid input should be a nine-box str representation"
        return int(input_str, 2)
Let’s run it…
with open(INPUT_FILE, mode="rt") as f:
    data = f.read().split("\n\n")
    
image_enhance_map, input_img = data
trench_image = ImageArray(input_img, image_enhance_map)
# Part 1 - Stop at 2 cycles
for i in range(2):
    logger.debug("Image iteration %d", i)
    trench_image = trench_image.enhance()
logger.info("Part 1: Lit=%d", trench_image.lit_count) 
But you may have spotted I’m doing some stuff in the code that wasn’t in my game plan. That’s because I tried my game plan with the sample data, and it worked!
“Well, that was easy,” I thought to myself.
But when I tried it with the actual data, it didn’t work (initially). Figures. It took me a while to work out why…
. at index 0 of the algorithm, and a # at index 511 of the algorithm.  (I.e. the index positions created by a 3x3 grid that are all . or all # respectively.)But the real data has a sneaky difference!
# at index 0 of the algorithm, and a . at index 511 of the algorithm. I.e. our first and last index chars are swapped! At least, my real data does, but I’m guessing everyone’s does!enhance() algorithm toggles whether the infinite canvas is lit or dark!So, we have to be mindful of whether our infinite canvas is made up of lit pixels, or dark pixels.  And this is why my ImageArray class:
canvas_char in the __init__() method, but defaults to dark (“.”).canvas_char should be lit or dark following this iteration of enhance(), by evaluating whether a 3x3 grid of the current infinite canvas pixel will return a “#” or a “.”. And of course, this is entirely dependent on the algorithm we’re given._image_enhancement_index() method still determines the value returned by the 3x3 grid of pixels centered on this pixel.  But if any of those pixels are outside of the current bounds (i.e. in the infinite canvas), then it must determine whether those infinite canvas pixels are lit or dark.Phew, that fixed it!
Now we need to enhance 50 times.
Hurrah! No changes required. The code above is really efficient, so you can just run it for 50 cycles, no problem.
Here, I’m starting at 2, since we’ve already done 2 iterations, so there’s no need to repeat them.
# Part 2 - Stop at 50 cycles
for i in range(2, 50):
    logger.debug("Image iteration %d", i)
    trench_image = trench_image.enhance()
logger.info("Part 2: Lit=%d", trench_image.lit_count)   
It would be cool to render an animation of our enhancing image!  So, I’m going to use PIL again.
Some additional setup:
from PIL import Image, ImageDraw, ImageFont
...
RENDER = True
IMAGE_SIZE = 400
OUTPUT_FILE = Path(SCRIPT_DIR, "output/trench_anim.gif")
Let’s add some code to render a pretty image, for a given ImageArray instance. We just add this method to the ImageArray class:
    def render_image(self) -> Image.Image:
        """ Render as an image """
        width = (self._max_x+1) - self._min_x
        height = (self._max_y+1) - self._min_y
        image = Image.new(mode='RGB', size=(width, height))
        image_data = []
        
        for y in range(width):
            for x in range(height):
                x_val = x + self._min_x
                y_val = y + self._min_y
                point = Point(x_val, y_val)
                if point in self._pixels:
                    image_data.append((255, 255, 255)) # lit pixels
                else:
                    image_data.append((128, 0, 0)) # dark pixels
        image.putdata(image_data)
        return image   
This is what it does:
PIL Image object.list, to store RGB values as tuples.Point from each location in the ImageArray.Point is lit (i.e. in the set), set it to white. Else, set it to dark red. Ultimately, this creates a flattened list of RGB tuples.putdata() method to assign the RGB values to the Image object.All well and good. But now we need to render this image for each iteration, and turn it into an animation. To do this, I’ve created an Animator class:
class Animator():
    """ Creates an animation file of specified target size. """
    
    FONT = ImageFont.truetype('arial.ttf', 24)
    TEXT_COLOUR = (128, 128, 220, 255) # light blue
    
    def __init__(self, file: Path, size: int, 
                 duration: int, loop_animation=False, include_frame_count: bool=False) -> None:
        """ Create an Animator. Suggest the file should be a .gif.
        Target size is in pixels. Frame duration is in ms. Optionally superimpose frame count. """
        self._outputfile = file
        self._frames = []
        self._target_size = size
        self._frame_duration = duration
        self._add_frame_count = include_frame_count
    
    def add_frame(self, image: Image.Image):
        """ Add a frame by passing in a PIL Image. The frame is resized to the target size. """
        image = image.resize((self._target_size, self._target_size), Image.NEAREST)
        if self._add_frame_count:
            self._superimpose_frame_count(image)
        self._frames.append(image)
    
    def _superimpose_frame_count(self, image: Image.Image):
        """ Add our cycle count text to the bottom right of the image """
        image_draw = ImageDraw.Draw(image)        
        text = str(len(self._frames))
        textwidth, textheight = image_draw.textsize(text, Animator.FONT)
        im_width, im_height = image.size
        margin = 10     # margin we want round the text to the edge
        x_locn = im_width - textwidth - margin
        y_locn = im_height - textheight - margin
        image_draw.text((x_locn, y_locn), text, font=Animator.FONT, fill=Animator.TEXT_COLOUR)
        
    def save(self):
        """ Save to the target file. Creates parent folder if it doesn't exist. """
        dir_path = Path(self._outputfile).parent
        if not Path.exists(dir_path):
            Path.mkdir(dir_path)
        logger.info("Animation saved to %s", self._outputfile)
        self._frames[0].save(self._outputfile, save_all=True, 
                             duration=self._frame_duration, append_images=self._frames[1:])
We initialise an Animator instance, passing in the file we want to save to, the image size (in pixels), the length of each frame (in milliseconds), and whether we want a frame count superimposed on the image.  The Animator:
_frames instance variable.add_frame(), which we will call from our ImageArray objects.ImageDraw object, to add our frame count text onto each frame.save() method, which:
    save() on the first frame, which is itself a PIL Image object. We pass in all the remaining frames to the append_images attribute.Now we need to make a couple of additional modifications to the ImageArray class:
def __init__(self, image_data: str|set, img_enhancement_map: str, 
             canvas_char='.', animator: Animator=None) -> None:
    
    ...
    
    # Only render the image frame, if we have an Animator reference
    self._animator = animator
    if animator is not None:
        animator.add_frame(self.render_image())
If we pass an Animator instance to the ImageArray constructor, then the ImageArray will store it, and call animator.add_frame(), passing in the output from its render_image() method.
Also, we need to make a change to our enhance() method:
def enhance(self) -> ImageArray:
    
    ...
    return ImageArray(new_pixels, self._img_enhancement_map, 
                      canvas_char=next_canvas_char, animator=self._animator)
This ensures that we always pass the existing Animator object to the new ImageArray object we create, whenever we run enhance(). This way, each new ImageArray() will add a frame to the same Animator.
And finally, let’s update our calling code, so that it looks like this:
with open(INPUT_FILE, mode="rt") as f:
    data = f.read().split("\n\n")
    
image_enhance_map, input_img = data
if RENDER:
    animator = Animator(file=OUTPUT_FILE, size=IMAGE_SIZE, 
                        duration=150, loop_animation=True, include_frame_count=True)
    trench_image = ImageArray(input_img, image_enhance_map, animator=animator)
else:
    trench_image = ImageArray(input_img, image_enhance_map)
# Part 1 - Stop at 2 cycles
for i in range(2):
    logger.debug("Image iteration %d", i)
    trench_image = trench_image.enhance()
logger.info("Part 1: Lit=%d", trench_image.lit_count)   
# Part 2 - Stop at 50 cycles
for i in range(2, 50):
    logger.debug("Image iteration %d", i)
    trench_image = trench_image.enhance()
    
logger.info("Part 2: Lit=%d", trench_image.lit_count)
if animator:
    animator.save()
The output looks like this:
21:29:48.666:INFO:__main__:     Part 1: Lit=5464
21:29:48.666:INFO:__main__:     Part 2: Lit=19228
21:29:48.666:INFO:__main__:     Animation saved to c:\Users\djl\LocalDev\Python\Advent-of-Code\src\AoC_2021\d20_img_enhancement_pixel_map_bin_indexing\output\trench_anim.gif
21:29:49.595:INFO:__main__:     Execution time: 4.8548 seconds
The animation of the sample input looks like this:
And the animation of the real data looks like this: