Punchagle 3 - 250 (Algo)

Write-up by GenericNickname

Created: 2015-12-07

Problem

Same format as Punchagle 1 & 2, except this time, some shapes have been omitted, AND the triangles are randomly rotated.

sctf{FLAG} is the format.

Files

images.7z

Hint

Imaging libraries REALLY ARE NECESSARY if you haven't figured that out yet!

Answer

Overview

I'm assuming you have read the solution to Punchagle and Punchagle 2, if not they can found here and here.

Rotate all the triangles so that they are readable. Read the symbols off the images as their decimal values or a marker for blanks and then locate the flag in the output, probably with the aid of an imaging library (as indicated by the hint). This is the same process as one and two, but with accounting for rotation.

Details

Upon looking at all of the files, it is clear that the previous solution is not going to be successful, at least not without some method of correcting the rotation of the triangles.

Rotation is by far the worst part of this problem, and it was only made worse due to me being lazy. I didn't want to learn opencv or some imaging library other than Pillow, so I wrote a script to rotate all the images so that they were all either upright or almost upright.

Just fixing the rotation turned into a three step process. First, I wrote some logic to locate the two corners of the triangle that should be at the bottom, by scanning along the edges of the images until I found them, making sure to only match once per side:

from PIL import Image
import os
rootdir = 'images'
fixeddir = 'fixedImages'
count = 0
result = ""

for subdir, dirs, files in os.walk(rootdir):
    for f in files:
        count += 1
        if count % 100 == 0 : print count
        test = Image.open(os.path.join(subdir, f))
        yellow = (255, 255, 0)
        cor1 = (-1, -1)
        cor2 = (-1, -1)
        inset = 0
        down = up = left = right = False
        while cor2 == (-1, -1) :
            x = test.width - inset
            y = test.height - inset
            if (not down) :
                for i in range (1, x) :
                    if test.getpixel((x - i, y - 1)) == yellow :
                        down = True
                        if(cor1 == (-1, -1)) :
                            cor1 = (x - i, y - 1)
                        else :
                            cor2 = (x - i, y - 1)
                        break
            if(cor1 == (-1, -1) or cor2 == (-1, -1) and not left) :
                for i in range (1, y) :
                    if test.getpixel((inset, y - i)) == yellow :
                        left = True
                        if(cor1 == (-1, -1)) :
                            cor1 = (inset, y - i)
                        else :
                            cor2 = (inset, y - i)
                        break
            if(cor1 == (-1, -1) or cor2 == (-1, -1) and not up) :
                for i in range (1, x) :
                    if test.getpixel((inset + i, inset)) == yellow :
                        up = True
                        if(cor1 == (-1, -1)) :
                            cor1 = (inset + i, inset)
                        else :
                            cor2 = (inset + i, inset)
                        break
            if(cor1 == (-1, -1) or cor2 == (-1, -1) and not right) :
                for i in range (1, y) :
                    if test.getpixel((x - 1, inset + i)) == yellow :
                        right = True
                        if(cor1 == (-1, -1)) :
                            cor1 = (x - 1, inset + i)
                        else :
                            cor2 = (x - 1, inset + i)
                        break
            if(cor2 == (-1, -1)) :
                inset += 1

Once I had the two coordinates, I decided to pick one and rotate it so that it should be in the bottom right corner. This was done by comparing the quandrants of the cordinates after making them relative to the center of the image:

def getQuadrant(c1) :
    if c1[0] > 0:
        if c1[1] > 0 : return 1
        else : return 4
    else :
        if c1[1] > 0 : return 2
        else : return 3

def getPointToRotate(c1, c2, center) :
    c1 = (c1[0] - center[0], (test.height - c1[1]) - center[1])
    c2 = (c2[0] - center[0], (test.height - c2[1]) - center[1])
    q1 = getQuadrant(c1)
    q2 = getQuadrant(c2)
    used = [False] * 5
    used[q1] = True
    used[q2] = True
    ##c1 will always be the lower quadrant
    if q1 > q2 :
        temp = c1
        c1 = c2
        c2 = temp
    if q1 == q2 :
        if q1 == 1 : return c1 if c1[1] > c2[1] else c2
        if q1 == 2 : return c1 if c1[0] < c2[0] else c2
        if q1 == 3 : return c1 if c1[1] < c2[1] else c2
        if q1 == 4 : return c1 if c1[0] > c2[0] else c2
    if used[1] and used[2] : return c2
    if used[1] and used[3] : return c1
    if used[1] and used[4] : return c1
    if used[2] and used[3] : return c2
    if used[2] and used[4] : return c2
    if used[3] and used[4] : return c2

Finally, I used some vector math to get the angle between the bottom right corner and my cordinate:

def dotproduct(v1, v2):
  return sum((a*b) for a, b in zip(v1, v2))

def length(v):
  return math.sqrt(dotproduct(v, v))

def angle(v1, v2):
  return math.degrees(math.acos(dotproduct(v1, v2) / (length(v1) * length(v2))))

def pDistance(p1, p2) :
    return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)

def getQuadrant(c1) :
    if c1[0] > 0:
        if c1[1] > 0 : return 1
        else : return 4
    else :
        if c1[1] > 0 : return 2
        else : return 3

def isLeftMoreThanRight(img):
    white = (255, 255, 255)
    left = 0
    right = 0
    for i in range(0, img.height):
        if img.getpixel(((int)(img.width / 8), i)) == white:
            left = left + 1
    for i in range(0, img.height):
        if img.getpixel(((int)(img.width / 8 + (img.width / 4) + (img.width / 2)), i)) == white:
            right = right + 1
    return (left > right)

and then I used Pillow's rotate function to rotate the images and save them to a new directory (this is in the file system for loop from earlier):

        toRotate = 0
        distance = pDistance(cor1, cor2)
        center = (test.width / 2, test.height / 2)
        orig = (distance / 2, -distance / 2)
        right = getPointToRotate(cor1, cor2, center)
        toRotate = (angle((right), (orig)))
        if isLeftMoreThanRight(test):
            toRotate *= -1
        newImg = test.rotate(math.ceil(toRotate))
        newImg.save(os.path.join(fixeddir, f))

The full code for the rotation can be downloaded here.

This code doesn't work perfectly, as some of the images were off by a few degrees (this was particularly bad on smaller triangles), and it also had some issues dealing with triangles that had already been correctly oriented. Some of these issues we fixed by hand, and others we ignored and hoped wouldn't hinder us.

Now that I had correctly oriented triangles (mostly), I had to read the symbols off of them. Unfortunately, the old code from Punchagle and Punchagle 2 will not work without some modifications, because the rotation messed up the numbers and symbols.

The first step was to get rid of the black borders that all of the images had due to their canceled out rotation, and in the function I wrote for doing so, I also allowed it to try to correct minor imperfections in the initial rotation code:

def findSquareEdges(img, tries = 0) :
    tries = tries + 1
    w = img.width
    h = img.height
    foundEdge = False
    for y in range(0, h / 2) :
        l = img.getpixel((w / 4, y))
        r = img.getpixel((w * 3 / 4, y))
        if l != r and tries < 3:
            if(l == (0, 0, 0)) :
                img = img.rotate(-1)
                return findSquareEdges(img, tries)
            else :
                img = img.rotate(1)
                return findSquareEdges(img, tries)
        if l != (0, 0, 0) or r != (0, 0, 0) :
            x = y
            x2 = w - y
            if(img.getpixel((x, h / 2)) == (0, 0, 0)) : x = x + 1
            if x2 + 1 < w and (img.getpixel((x2 + 1, h / 2)) != (0, 0, 0)) : x2 = x2 - 1
            return img.crop((x, y, x2, h - y))

Now to match symbols that had been skewed by rotation, I found a function that would return a percentage of how different two images were:

from PIL import ImageChops
import math, operator

from itertools import izip
def diffImages (i1, i2) :
    assert i1.mode == i2.mode, "Different kinds of images."
    assert i1.size == i2.size, "Different sizes."

    pairs = izip(i1.getdata(), i2.getdata())
    if len(i1.getbands()) == 1:
        # for gray-scale jpegs
        dif = sum(abs(p1-p2) for p1,p2 in pairs)
    else:
        dif = sum(abs(c1-c2) for p1,p2 in pairs for c1,c2 in zip(p1,p2))

    ncomponents = i1.size[0] * i1.size[1] * 3
    perc = (dif / 255.0 * 100) / ncomponents
    return perc

and modified my symbol matching to go through all the symbols and figure out which one the data from the triangle was most similar to (I also skipped indices 3, 4, 8, and 9, because those were the missing symbols for this set of images):

def getImageSymbol(syms, img) :
    least = (100, -1)
    for x in range(0, len(syms)) :
        if x == 3 or x == 4 or x == 8 or x == 9 : continue
        diff = diffImages(syms[x], img)
        if diff < least[0] : least = diff, x
    if least[1] == 10 : return -1
    return least[1]

To accomadate blanks with this new matching system, I had to update my symbol table to have a blank tile as well:
punchagle 3 symbols

Fortunately, I was able to use the same readTriangleRow function as before, but I did have to make a minor modification to readTriangle. The numbers were by far more messed up than the symbols after the rotation, so instead I used some basic math to determine how many rows each triangle should have:

def readTriangle(symbols, numerals, img) :
    rows = int(math.ceil((img.height - 40) / 20)) #this is the different line
    res = ""
    for x in range(1, rows + 1) :
        res += readTriangleRow(symbols, img, x)
    return res

Finally, all the code gets run by another directory for loop, and this time the output is one line of all the triangles (although I later learned that because of how my code reads file systems this wouldn't have worked), and then each triangle separated out to make them easier to look through:

out = open('symbolsOut3.txt', 'w')
import os
rootdir = 'fixedImages'
count = 0
result = ""
result1 = ""

for subdir, dirs, files in os.walk(rootdir):
    for f in files:
        if f[0] == 'a' : continue
        count += 1
        print f
        if count % 100 == 0 : print count
        test = findSquareEdges(Image.open(os.path.join(subdir, f)).convert("RGB"))
        result += '\nTriangle: ' + f + '\n'
        thisTriangle = readTriangle(symbols, numerals, test)
        result += thisTriangle
        result1 += thisTriangle
out.write(result1)
out.write(result)
out.close()

The full code for reading the triangles can be downloaded here.

Since I knew that 3, 4, 8, and 9 were all omitted from the file and replaced by x's, i searhced for 115xx11610212x in the output, but that was unsucessful. However, 15xx11610212x did show up, and a 125 could be seen later in the same triangle, so I figured it was the flag.

This was the output: 15xx11610212x11511611111211610x10111510111211x111xx11511210x115125. It was then that I realized that this flag must have been slightly across triangles, with the missing open circle for the first 1 of 115 in the previous triangle. Because the x's were more spread out in this flag, it was easier to decode by hand, so I didn't bother to use a brute force method as I did for Punchagle 2.

The finals numbers were 1159911610212311511611111211610410111510111211411198115112108115125, or sctf{stoptheseprobsplease} when turned into ASCII.

Flag

sctf{stoptheseprobspls}

Extra Information

This solution would not have worked if the flag had been in one of the triangles that couldn't be read entirely due to imperfect rotation, or if the flag had been more split up between the two triangles.