'Rounding errors using PIL.Image.Transform

I have high resolution scans of old navigational maps, that I have turned into map tiles with gdal2tiles. Now I want to write code for a live video stream that records a panning movement between two random points on the maps.

Initially, I had the code working by generating an image for each videoframe, that was assembled from a grid of map tiles. These are small jpg files of 256px x 256px. The next videoframe would then show the same map, but translated a certain amount in the x and y direction.

But opening each jpg with Image.open proved to be a bottleneck. So I tried to make the process faster by reusing the opened tiles until they disappeared off the frame, and only opening fresh tiles as needed. I basically first translate the whole image like so: newframe = oldframe.transform(oldframe.size, Image.AFFINE, data). Then I add a row of fresh tiles on top (or bottom, depending on direction of the panning motion), and on the side.

The problem

I seem to run into some rounding errors (I think), because the opened tiles do not always line up well with the existing, translated content. I have thin lines appearing. But I can't figure out where the rounding errors may come from. Nor how to avoid them.

Here is some code which shows the lines appearing, using colored empty images rather than the map tiles:


import time
import math
from random import randint, uniform
from PIL import Image
import numpy as np
import cv2

FRAMESIZE = (1920, 1080)
TILESIZE = 256

class MyMap:
    '''A map class, to hold all its properties'''

    def __init__(self) -> None:
        self.maxzoom = 8
        self.dimensions = (40000, 30000)
        self.route, self.zoom = self.trajectory()
        pass

    def trajectory(self):
        '''Calculate random trajectories from map dimensions'''
        factor = uniform(1, 8)
        extentwidth  = FRAMESIZE[0] * factor
        extentheight = FRAMESIZE[1] * factor
        mapwidth, mapheight = (self.dimensions)

        zdiff = math.log2(factor)
        newzoom = math.ceil(self.maxzoom - zdiff)

        a = (randint(0 + math.ceil(extentwidth / 2), mapwidth - math.floor(extentwidth / 2)),
             randint(0 + math.ceil(extentheight / 2), mapheight - math.floor(extentheight / 2)))  # Starting point
        b = (randint(0 + math.ceil(extentwidth / 2), mapwidth - math.floor(extentwidth / 2)),
             randint(0 + math.ceil(extentheight / 2), mapheight - math.floor(extentheight / 2)))  # Ending point

        x_distance = b[0] - a[0]
        y_distance = b[1] - a[1]

        distance = math.sqrt((x_distance**2)+(y_distance**2)) # Pythagoras
        speed = 3 * factor # pixels per 25th of a second (video framerate)
        steps = math.floor(distance / speed)

        trajectory = []
        for step in range(steps):
            x = a[0] + step * x_distance / steps
            y = a[1] + step * y_distance / steps
            trajectory.append((x, y))
        
        return trajectory, newzoom

    def CreateFrame(self, point, oldframe = None):

        x = round(self.route[point][0] / (2 ** (self.maxzoom - self.zoom)))
        y = round(self.route[point][1] / (2 ** (self.maxzoom - self.zoom)))

        if oldframe:
            xtrns = x - round(self.route[point - 1][0] / (2 ** (self.maxzoom - self.zoom)))
            ytrns = y - round(self.route[point - 1][1] / (2 ** (self.maxzoom - self.zoom)))
            # print(x, self.route[point - 1][0] / (2 ** (self.maxzoom - self.zoom)))

        west  = int(x - FRAMESIZE[0] / 2)
        east  = int(x + FRAMESIZE[0] / 2)
        north = int(y - FRAMESIZE[1] / 2)
        south = int(y + FRAMESIZE[1] / 2)
        
        xoffset = west % TILESIZE
        yoffset = north % TILESIZE

        xrange = range(math.floor(west / TILESIZE), math.ceil(east / TILESIZE))
        yrange = range(math.floor(north / TILESIZE),  math.ceil(south / TILESIZE))

        if oldframe:
            
            data = (
                1, #a
                0, #b
                xtrns, #c +left/-right
                0, #d
                1, #e
                ytrns #f +up/-down
            )
            
            newframe = oldframe.transform(oldframe.size, Image.AFFINE, data)
            if ytrns < 0:
                singlerow_ytile = yrange.start
            elif ytrns > 0:
                singlerow_ytile = yrange.stop - 1

            if ytrns != 0:
                for xtile in xrange:
                    try:
                        tile = Image.new('RGB', (TILESIZE, TILESIZE), (130,100,10))
                        newframe.paste(
                            tile,
                            (
                                (xtile - west // TILESIZE) * TILESIZE - xoffset,
                                (singlerow_ytile - north // TILESIZE) * TILESIZE - yoffset
                            )
                        )
                    except: 
                        tile = None


            if xtrns < 0:
                singlerow_xtile = xrange.start
            elif xtrns > 0:
                singlerow_xtile = xrange.stop - 1

            if xtrns != 0:
                for ytile in yrange:
                    try:
                        tile = Image.new('RGB', (TILESIZE, TILESIZE), (200, 200, 200))
                        newframe.paste(
                            tile,
                            (
                                (singlerow_xtile - west // TILESIZE) * TILESIZE - xoffset,
                                (ytile - north // TILESIZE) * TILESIZE - yoffset
                            )
                        )
                    except: 
                        tile = None

        else:
            newframe = Image.new('RGB',FRAMESIZE)
            for xtile in xrange:
                for ytile in yrange:
                    try:
                        tile = Image.new('RGB', (TILESIZE, TILESIZE), (100, 200, 20))
                        newframe.paste(
                            tile,
                            (
                                (xtile - west // TILESIZE) * TILESIZE - xoffset,
                                (ytile - north // TILESIZE) * TILESIZE - yoffset
                            )
                        )
                    except: 
                        tile = None

        return newframe

def preparedisplay(img, ago):
    quit = False
    open_cv_image = np.array(img) 
    # Convert RGB to BGR 
    open_cv_image = open_cv_image[:, :, ::-1].copy()

    # Write some Text
    font                   = cv2.FONT_HERSHEY_TRIPLEX
    bottomLeftCornerOfText = (10,1050)
    fontScale              = 1
    fontColor              = (2,2,2)
    thickness              = 2
    lineType               = 2

    cv2.putText(open_cv_image,f"Press q to exit. Frame took {(time.process_time() - ago) * 100}", 
        bottomLeftCornerOfText, 
        font, 
        fontScale,
        fontColor,
        thickness,
        lineType)


    return open_cv_image


#===============================================================================

quit = False
while not quit:

    currentmap = MyMap()
    previousframe = None
    step = 0

    while step < len(currentmap.route):
        start = time.process_time()
        
        currentframe = currentmap.CreateFrame(step, previousframe)
        previousframe = currentframe
        
        cv2.imshow('maps', preparedisplay(currentframe, start))
        timeleft = (time.process_time() - start)
        # print(timeleft)
        # if cv2.waitKey(40 - int(timeleft * 100)) == ord('q'):
        if cv2.waitKey(1) == ord('q'):
            # press q to terminate the loop
            cv2.destroyAllWindows()
            quit = True
            break
        
        step += 1


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source