├── .gitignore ├── README.md ├── code ├── README.md ├── __init__.py ├── heap.py ├── parse.py ├── shader.py ├── simplify_polygon.py ├── test.jpg └── unwrap.py ├── img ├── ascii.png ├── comparison.gif ├── diag.gif ├── glitch_gif.py ├── header.png ├── jelly.glitch ├── make_anim_diag.py ├── parse.jpg ├── screenshot.jpg ├── termcolor.py ├── test.jpg ├── title_howitworks.png ├── title_makingvideos.png ├── unwrap.jpg ├── vid_comp.jpg └── vid_unwrap.jpg ├── unwrap_video.py └── vid ├── README.md └── trailer.mp4 /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Super Hexagon Unwrapper](img/header.png) 2 | 3 | [![comparison](img/comparison.gif)](https://vimeo.com/78922669) 4 | 5 | __[Super Hexagon](http://superhexagon.com/)__ (shown above on the left) is a 6 | game by Terry Cavanagh. This project warps that image into a different 7 | perspective (shown above on the right). Angular motion is converted into 8 | lateral motion, resulting in two different representations for the same 9 | gameplay. 10 | 11 | [>> Watch the video](https://vimeo.com/78922669) 12 | 13 | This project is written in Python. It employs Computer Vision algorithms 14 | provided by __[SimpleCV](http://www.simplecv.org/)__ to establish a reference 15 | frame in the image. Then it warps (or "unwraps") the image based on that 16 | reference frame, using OpenGL fragment shaders. 17 | 18 | [>> Learn how it works](code) 19 | 20 | ``` 21 | Unwrap a video: 22 | > python unwrap_video.py vid/trailer.mp4 23 | ``` 24 | 25 | ![screenshot](img/screenshot.jpg) 26 | 27 | ``` 28 | Script options 29 | 30 | --help (show all options) 31 | --start N (start at frame N) 32 | --stop N (stop at frame N) 33 | --out DIR (dump all frames into the given DIR) 34 | 35 | Script Dependencies 36 | 37 | * Python 2.7 38 | * SimpleCV 1.3 (for video processing and computer vision) 39 | * Pyglet (for fast image transforms with OpenGL shaders) 40 | ``` 41 | 42 | * [Encode a video](vid) 43 | 44 | 45 | 46 | 47 | _This program is free software: you can redistribute it and/or modify it under the terms 48 | of the GNU General Public License Version 3 as published by the Free Software Foundation._ 49 | -------------------------------------------------------------------------------- /code/README.md: -------------------------------------------------------------------------------- 1 | ![how it works](../img/title_howitworks.png) 2 | 3 | The following image "test.jpg" is used for testing the code, as shown in the 4 | following examples. 5 | 6 | ![test](../img/test.jpg) 7 | 8 | We find the vertices of the center polygon, then extend axis lines from the 9 | center through each vertex (shown in red below). This creates the sectors of 10 | our reference frame that we will use to unwrap the image. The following script 11 | contains the code that locates the vertices (commented for your reading). 12 | Running it directly will parse and display an image for testing. 13 | 14 | ``` 15 | Parse a test image: 16 | > python parse.py 17 | ``` 18 | 19 | ![parse](../img/parse.jpg) 20 | 21 | Once we have the reference frame, we can build a mathematical projection to 22 | unwrap the image such that each of the detected axis lines are made vertical 23 | (math details below). Then we apply it to the image with an OpenGL fragment 24 | shader, which uses GPU acceleration for fast pixel mapping. (This was a little 25 | awkward to get working because OpenGL is not intended for batch 26 | image-processing.) The following script contains the code that does this 27 | (commented for your reading). Running it directly will parse, unwrap, and 28 | display an image for testing. 29 | 30 | ``` 31 | Unwrap a test image: 32 | > python unwrap.py 33 | ``` 34 | 35 | ![unwrap](../img/unwrap.jpg) 36 | 37 | Notice that the unwrapped image has black gaps signifying pixels that could not 38 | be retrieved from the original image due to its limited window. Also notice 39 | that the projection can be quite distorted in areas. This results from 40 | inherent errors in the reference frame and the lack of mathematics in the 41 | projection to account for 3D rotation of the image. The projection as it is 42 | now assumes that all walls in a sector are parallel, which seems to only be 43 | true when the optical axis is perpendicular to the surface of the game. 44 | Following this assumption, here is an animated excerpt from unwrap.py that 45 | describes the projection: 46 | 47 | ![diagram](../img/diag.gif) 48 | 49 | -------------------------------------------------------------------------------- /code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/code/__init__.py -------------------------------------------------------------------------------- /code/heap.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is an implementation of a binary heap that allows 3 | arbitrary node values to be changed. 4 | 5 | (used for polygon simplifying in simplify_blob.py) 6 | 7 | See unit tests at the end of this file. 8 | """ 9 | 10 | class EmptyHeapException(Exception): 11 | def __init__(self,msg): 12 | self.msg = msg 13 | 14 | class Heap: 15 | 16 | def __init__(self, nodes, is_higher, on_index_change=None): 17 | """ 18 | nodes = sequence of objects of any type 19 | is_higher = function(a,b) returning true if 'a' is higher priority than 'b' 20 | on_index_change = callback function(node,i) called when 'node' changes to index 'i' 21 | """ 22 | self.array = [] 23 | self.is_higher = is_higher 24 | self.on_index_change = on_index_change 25 | for node in nodes: 26 | self.push(node) 27 | 28 | def normalize_index(self,i): 29 | return i if (0 <= i and i < len(self.array)) else None 30 | 31 | def get_parent_index(self,i): 32 | return self.normalize_index((i+1)/2 - 1) 33 | 34 | def get_child_indexes(self,i): 35 | i = (i+1)*2 - 1 36 | return self.normalize_index(i), self.normalize_index(i+1) 37 | 38 | def place_node_at_index(self,node,i): 39 | self.array[i] = node 40 | if self.on_index_change: 41 | self.on_index_change(node, i) 42 | 43 | def swap_nodes(self,i0,i1): 44 | node0 = self.array[i0] 45 | node1 = self.array[i1] 46 | self.place_node_at_index(node0, i1) 47 | self.place_node_at_index(node1, i0) 48 | 49 | def is_index_higher(self,i0,i1): 50 | if i0 is None or i1 is None: 51 | return False 52 | return self.is_higher(self.array[i0],self.array[i1]) 53 | 54 | def try_promote_node(self,i): 55 | parent_i = self.get_parent_index(i) 56 | if self.is_index_higher(i, parent_i): 57 | self.swap_nodes(i, parent_i) 58 | self.try_promote_node(parent_i) 59 | 60 | def try_demote_node(self,i): 61 | child_i, next_child_i = self.get_child_indexes(i) 62 | if self.is_index_higher(next_child_i, child_i): 63 | child_i = next_child_i 64 | if self.is_index_higher(child_i, i): 65 | self.swap_nodes(i, child_i) 66 | self.try_demote_node(child_i) 67 | 68 | def reorder_node(self,i): 69 | self.try_promote_node(i) 70 | self.try_demote_node(i) 71 | 72 | def push(self, node): 73 | self.array.append(None) 74 | i = len(self.array)-1 75 | self.place_node_at_index(node,i) 76 | self.try_promote_node(i) 77 | 78 | def peek(self): 79 | if not self.array: 80 | raise EmptyHeapException('cannot peek at empty heap') 81 | return self.array[0] 82 | 83 | def pop(self): 84 | 85 | # stop if nothing to pop 86 | if not self.array: 87 | raise EmptyHeapException('cannot pop empty heap') 88 | 89 | # get root node, and nullify its index 90 | node = self.array[0] 91 | if self.on_index_change: 92 | self.on_index_change(node, None) 93 | 94 | # replace root node with last node 95 | self.place_node_at_index(self.array[-1], 0) 96 | 97 | # remove last node 98 | self.array.pop() 99 | 100 | # re-order heap from new root node 101 | if len(self.array) > 1: 102 | self.try_demote_node(0) 103 | 104 | # return the popped node 105 | return node 106 | 107 | ###################################################################### 108 | 109 | import unittest 110 | import random 111 | 112 | class TestHeapOrder(unittest.TestCase): 113 | 114 | def setUp(self): 115 | """ 116 | Create a min heap of random values. 117 | """ 118 | self.count = 1000 119 | self.elements = [random.randint(1,50) for i in xrange(self.count)] 120 | def is_higher(a,b): 121 | return a < b 122 | self.heap = Heap(self.elements, is_higher) 123 | 124 | def assert_order(self): 125 | """ 126 | Assert that the heap is ordered by popping all elements from it 127 | while ensuring each successive element is >= previous element. 128 | """ 129 | prev_a = self.heap.pop() 130 | num_popped = 1 131 | while True: 132 | try: 133 | a = self.heap.pop() 134 | num_popped += 1 135 | self.assertLessEqual(prev_a, a) 136 | prev_a = a 137 | except EmptyHeapException: 138 | break 139 | self.assertEqual(num_popped, self.count) 140 | 141 | def test_initial_order(self): 142 | self.assert_order() 143 | 144 | def change_value_at_index(self, i): 145 | """ 146 | Change the value at the given index to a random value, then shift the 147 | node to its correct place. 148 | """ 149 | self.heap.array[i] = random.randint(1,50) 150 | self.heap.reorder_node(i) 151 | 152 | def test_reorder(self): 153 | """ 154 | Asset that the heap remains ordered after changing many elements to 155 | random values. 156 | """ 157 | for i in xrange(self.count): 158 | self.change_value_at_index(random.randrange(self.count)) 159 | self.assert_order() 160 | 161 | if __name__ == "__main__": 162 | unittest.main() 163 | -------------------------------------------------------------------------------- /code/parse.py: -------------------------------------------------------------------------------- 1 | """ 2 | This parses a frame from Super Hexagon to extract the features we want, using 3 | Computer Vision. 4 | """ 5 | 6 | from SimpleCV import * 7 | 8 | from simplify_polygon import simplify_polygon_by_angle 9 | 10 | class ParsedFrame: 11 | """ 12 | This holds the features that we wish to extract from a Super Hexagon frame. 13 | """ 14 | 15 | def __init__(self, img, center_blob, center_img): 16 | """ 17 | img = SimpleCV Image object of the original image 18 | center_blob = SimpleCV Blob object of the center polygon 19 | center_img = SimpleCV Image object used to detect center polygon 20 | """ 21 | 22 | self.img = img 23 | self.center_img = center_img 24 | self.center_blob = center_blob 25 | 26 | # midpoint of the center polygon 27 | # (Just assume center of image is center point instead of using 28 | # center_blob.centroid()) 29 | w,h = img.size() 30 | self.center_point = (w/2, h/2) 31 | 32 | # vertices of the center polygon 33 | # (remove redundant vertices) 34 | self.center_vertices = simplify_polygon_by_angle(center_blob.hull()) 35 | 36 | def draw_frame(self, layer, linecolor=Color.RED, pointcolor=Color.WHITE): 37 | """ 38 | Draw the reference frame created by our detected features. 39 | (for debugging) 40 | 41 | layer = SimpleCV Image Layer object to receive the drawing operations 42 | """ 43 | 44 | # Draw the center polygon. 45 | width = 10 46 | layer.polygon(self.center_vertices, color=linecolor,width=width) 47 | 48 | # Draw the axes by extending lines from the center past the vertices. 49 | c = self.center_point 50 | length = 100 51 | for p in self.center_vertices: 52 | p2 = (c[0] + length*(p[0]-c[0]), c[1] + length*(p[1]-c[1])) 53 | layer.line(c,p2,color=linecolor,width=width) 54 | 55 | # Draw the reference points (center and vertices) 56 | def circle(p): 57 | layer.circle(p, 10, color=linecolor, filled=True) 58 | layer.circle(p, 5, color=pointcolor, filled=True) 59 | circle(self.center_point) 60 | for p in self.center_vertices: 61 | circle(p) 62 | 63 | def parse_frame(img): 64 | """ 65 | Parses a SimpleCV image object of a frame from Super Hexagon. 66 | Returns a ParsedFrame object containing selected features. 67 | """ 68 | 69 | # helper image size variables 70 | w,h = img.size() 71 | midx,midy = w/2,h/2 72 | 73 | # Create normalized images for targeting objects in the foreground or background. 74 | # (This normalization is handy since Super Hexagon's colors are inverted for some parts of the game) 75 | # fg_img = foreground image (bright walls, black when binarized) 76 | # bg_img = background image (bright space, black when binarized) 77 | fg_img = img 78 | if sum(img.binarize().getPixel(midx,midy)) == 0: 79 | fg_img = img.invert() 80 | bg_img = fg_img.invert() 81 | 82 | # Locate the CENTER blob. 83 | 84 | # We need to close any gaps around the center wall so we can detect its containing blob. 85 | # The gaps are resulting artifacts from video encoding. 86 | # The 'erode' function does this by expanding the dark parts of the image. 87 | center_img = bg_img.erode() 88 | 89 | # Locate the blob within a given size containing the midpoint of the screen. 90 | # Select the one with the largest area. 91 | center_blob = None 92 | blobs = center_img.findBlobs() 93 | max_area = 0 94 | if blobs: 95 | size = h * 0.6667 96 | for b in blobs: 97 | try: 98 | area = b.area() 99 | if b.width() < size and b.height() < size and b.contains((midx,midy)) and area > max_area: 100 | area = max_area 101 | center_blob = b 102 | except ZeroDivisionError: 103 | # blob 'contains' function throws this exception for some cases. 104 | continue 105 | 106 | if center_blob: 107 | return ParsedFrame(img, center_blob, center_img) 108 | else: 109 | return None 110 | 111 | if __name__ == "__main__": 112 | 113 | # Run a test by drawing the reference frame parsed from a screenshot. 114 | display = Display() 115 | p = parse_frame(Image('test.jpg')) 116 | if p: 117 | img = p.center_img.binarize() 118 | p.draw_frame(img.dl()) 119 | img.show() 120 | 121 | # Wait for user to close the window or break out of it. 122 | while display.isNotDone(): 123 | try: 124 | pass 125 | except KeyboardInterrupt: 126 | display.done = True 127 | if display.mouseRight: 128 | display.done = True 129 | display.quit() 130 | -------------------------------------------------------------------------------- /code/shader.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Pyglet helper class for using OpenGL shaders. 3 | 4 | taken from: http://swiftcoder.wordpress.com/2008/12/19/simple-glsl-wrapper-for-pyglet/ 5 | 6 | Copyright Tristam Macdonald 2008. 7 | 8 | Distributed under the Boost Software License, Version 1.0 9 | (see http://www.boost.org/LICENSE_1_0.txt) 10 | """ 11 | 12 | from pyglet.gl import * 13 | 14 | class Shader: 15 | # vert, frag and geom take arrays of source strings 16 | # the arrays will be concattenated into one string by OpenGL 17 | def __init__(self, vert = [], frag = [], geom = []): 18 | # create the program handle 19 | self.handle = glCreateProgram() 20 | # we are not linked yet 21 | self.linked = False 22 | 23 | # create the vertex shader 24 | self.createShader(vert, GL_VERTEX_SHADER) 25 | # create the fragment shader 26 | self.createShader(frag, GL_FRAGMENT_SHADER) 27 | # the geometry shader will be the same, once pyglet supports the extension 28 | # self.createShader(frag, GL_GEOMETRY_SHADER_EXT) 29 | 30 | # attempt to link the program 31 | self.link() 32 | 33 | def createShader(self, strings, type): 34 | count = len(strings) 35 | # if we have no source code, ignore this shader 36 | if count < 1: 37 | return 38 | 39 | # create the shader handle 40 | shader = glCreateShader(type) 41 | 42 | # convert the source strings into a ctypes pointer-to-char array, and upload them 43 | # this is deep, dark, dangerous black magick - don't try stuff like this at home! 44 | src = (c_char_p * count)(*strings) 45 | glShaderSource(shader, count, cast(pointer(src), POINTER(POINTER(c_char))), None) 46 | 47 | # compile the shader 48 | glCompileShader(shader) 49 | 50 | temp = c_int(0) 51 | # retrieve the compile status 52 | glGetShaderiv(shader, GL_COMPILE_STATUS, byref(temp)) 53 | 54 | # if compilation failed, print the log 55 | if not temp: 56 | # retrieve the log length 57 | glGetShaderiv(shader, GL_INFO_LOG_LENGTH, byref(temp)) 58 | # create a buffer for the log 59 | buffer = create_string_buffer(temp.value) 60 | # retrieve the log text 61 | glGetShaderInfoLog(shader, temp, None, buffer) 62 | # print the log to the console 63 | print buffer.value 64 | else: 65 | # all is well, so attach the shader to the program 66 | glAttachShader(self.handle, shader); 67 | 68 | def link(self): 69 | # link the program 70 | glLinkProgram(self.handle) 71 | 72 | temp = c_int(0) 73 | # retrieve the link status 74 | glGetProgramiv(self.handle, GL_LINK_STATUS, byref(temp)) 75 | 76 | # if linking failed, print the log 77 | if not temp: 78 | # retrieve the log length 79 | glGetProgramiv(self.handle, GL_INFO_LOG_LENGTH, byref(temp)) 80 | # create a buffer for the log 81 | buffer = create_string_buffer(temp.value) 82 | # retrieve the log text 83 | glGetProgramInfoLog(self.handle, temp, None, buffer) 84 | # print the log to the console 85 | print buffer.value 86 | else: 87 | # all is well, so we are linked 88 | self.linked = True 89 | 90 | def bind(self): 91 | # bind the program 92 | glUseProgram(self.handle) 93 | 94 | def unbind(self): 95 | # unbind whatever program is currently bound - not necessarily this program, 96 | # so this should probably be a class method instead 97 | glUseProgram(0) 98 | 99 | # upload a floating point uniform vector 100 | # this program must be currently bound 101 | def uniformfv(self, name, size, vals): 102 | if size in range(1, 5): 103 | { 1 : glUniform1fv, 104 | 2 : glUniform2fv, 105 | 3 : glUniform3fv, 106 | 4 : glUniform4fv 107 | # retrieve the uniform location, and set 108 | }[size](glGetUniformLocation(self.handle, name), len(vals)/size, (c_float * len(vals))(*vals)) 109 | 110 | # upload a floating point uniform 111 | # this program must be currently bound 112 | def uniformf(self, name, *vals): 113 | # check there are 1-4 values 114 | if len(vals) in range(1, 5): 115 | # select the correct function 116 | { 1 : glUniform1f, 117 | 2 : glUniform2f, 118 | 3 : glUniform3f, 119 | 4 : glUniform4f 120 | # retrieve the uniform location, and set 121 | }[len(vals)](glGetUniformLocation(self.handle, name), *vals) 122 | 123 | # upload an integer uniform 124 | # this program must be currently bound 125 | def uniformi(self, name, *vals): 126 | # check there are 1-4 values 127 | if len(vals) in range(1, 5): 128 | # select the correct function 129 | { 1 : glUniform1i, 130 | 2 : glUniform2i, 131 | 3 : glUniform3i, 132 | 4 : glUniform4i 133 | # retrieve the uniform location, and set 134 | }[len(vals)](glGetUniformLocation(self.handle, name), *vals) 135 | 136 | # upload a uniform matrix 137 | # works with matrices stored as lists, 138 | # as well as euclid matrices 139 | def uniform_matrixf(self, name, mat): 140 | # obtian the uniform location 141 | loc = glGetUniformLocation(self.Handle, name) 142 | # uplaod the 4x4 floating point matrix 143 | glUniformMatrix4fv(loc, 1, False, (c_float * 16)(*mat)) 144 | 145 | -------------------------------------------------------------------------------- /code/simplify_polygon.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility for reducing a polygon's vertices by creating a simpler approximation. 3 | """ 4 | 5 | from heap import Heap 6 | import math 7 | 8 | def vector_len(v): 9 | """Get length of 2D vector.""" 10 | x,y = v 11 | return math.sqrt(x*x + y*y) 12 | 13 | def vector_area(v0,v1): 14 | """Get area of the triangle created by two 2D vectors.""" 15 | cross = v0[0]*v1[1] - v0[1]*v1[0] 16 | return abs(cross)/2 17 | 18 | def vector_angle(v0,v1): 19 | """Get angle between two 2D vectors.""" 20 | dot = v0[0]*v1[0] + v0[1]*v1[1] 21 | den = vector_len(v0) * vector_len(v1) 22 | return math.acos(dot/den) 23 | 24 | class VertexNode: 25 | """ 26 | This represents a polygon as a doubly-linked list of 2D vertex nodes. 27 | (i.e. each vertex has a reference to its adjacent vertices) 28 | 29 | Also stores attributes calculated from adjacent vertices: 30 | * triangle area 31 | * angle 32 | """ 33 | 34 | def __init__(self, point): 35 | self.point = point 36 | self.next_node = None 37 | self.prev_node = None 38 | 39 | def get_adj_vectors(self): 40 | """Get the adjacent vertices relative to this vertex.""" 41 | v_prev = None 42 | v_next = None 43 | x0,y0 = self.point 44 | if self.prev_node: 45 | x,y = self.prev_node.point 46 | v_prev = (x-x0,y-y0) 47 | if self.next_node: 48 | x,y = self.next_node.point 49 | v_next = (x-x0,y-y0) 50 | return v_prev, v_next 51 | 52 | def calc_area(self): 53 | """Calculate the triangle area created by this vertex and its adjacents.""" 54 | v_prev, v_next = self.get_adj_vectors() 55 | if v_prev and v_next: 56 | self.area = vector_area(v_prev, v_next) 57 | else: 58 | self.area = None 59 | 60 | def calc_angle(self): 61 | """Calculate the area created by this vertex and its adjacents.""" 62 | v_prev, v_next = self.get_adj_vectors() 63 | if v_prev and v_next: 64 | self.angle = vector_angle(v_prev, v_next) 65 | else: 66 | self.angle = None 67 | 68 | def simplify_polygon_by(points, is_higher, should_stop, refresh_node): 69 | """ 70 | Simplify the given polygon by greedily removing vertices using a given priority. 71 | 72 | This is generalized from Visvalingam's algorithm, which is described well here: 73 | http://bost.ocks.org/mike/simplify/ 74 | 75 | is_higher = function(a,b) returns node higher in priority to be removed. 76 | should_stop = function(a) returns True if given highest priority node stops simplification. 77 | refresh_node = function(a) refreshes attributes dependent on adjacent vertices. 78 | """ 79 | length = len(points) 80 | 81 | # build nodes 82 | nodes = [VertexNode(p) for p in points] 83 | 84 | # connect nodes 85 | for i in xrange(length): 86 | prev_i = (i+length-1) % length 87 | next_i = (i+1) % length 88 | node = nodes[i] 89 | node.prev_node = nodes[prev_i] 90 | node.next_node = nodes[next_i] 91 | refresh_node(node) 92 | node.orig_index = i 93 | 94 | def on_index_change(node,i): 95 | """Callback that allows a node to know its location in the heap.""" 96 | node.heap_index = i 97 | 98 | heap = Heap(nodes, is_higher, on_index_change) 99 | 100 | while True: 101 | node = heap.peek() 102 | if should_stop(node): 103 | break 104 | heap.pop() 105 | 106 | # Close gap in doubly-linked list. 107 | prev_node, next_node = node.prev_node, node.next_node 108 | prev_node.next_node = next_node 109 | next_node.prev_node = prev_node 110 | 111 | # Refresh vertices that have new adjacents. 112 | refresh_node(prev_node) 113 | heap.reorder_node(prev_node.heap_index) 114 | refresh_node(next_node) 115 | heap.reorder_node(next_node.heap_index) 116 | 117 | # Return remaining points in their original order. 118 | return [node.point for node in sorted(heap.array, key=(lambda node: node.orig_index))] 119 | 120 | def simplify_polygon_by_area(points, epsilon=400): 121 | """ 122 | Simplify polygon by removing vertices whose removal results in the least 123 | change in area. (Visvalingam's algorithm) 124 | """ 125 | return simplify_polygon_by(points, 126 | is_higher = lambda a,b : a.area < b.area, 127 | should_stop = lambda node : node.area > epsilon, 128 | refresh_node = lambda node : node.calc_area()) 129 | 130 | def simplify_polygon_by_angle(points, epsilon=math.pi*0.8): 131 | """ 132 | Simplify polygon by removing vertices that are very close to sitting on a 133 | straight line between its neighbors. 134 | """ 135 | return simplify_polygon_by(points, 136 | is_higher = lambda a,b : a.angle > b.angle, 137 | should_stop = lambda node : node.angle < epsilon, 138 | refresh_node = lambda node : node.calc_angle()) 139 | 140 | -------------------------------------------------------------------------------- /code/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/code/test.jpg -------------------------------------------------------------------------------- /code/unwrap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Given a Super Hexagon image and the vertices of its center polygon, this module 3 | creates a new image by unwrapping it with the coordinate transform shown below: 4 | 5 | (Vertices of the center polygon are marked [1][2][3][4][5][6]) 6 | 7 | 8 | ORIGINAL UNWRAPPED 9 | 10 | [2] ________________________________________ 11 | ... | ^ 12 | ......... | | 13 | ..............X | | 14 | ................/.... | | 15 | ................../........ | | 16 | [3]........R(ANGLE)->./...........[1] | | 17 | ................../............ | |<--R(ANGLE)*4 18 | ................./\.<-ANGLE.... | | 19 | ................/ \........... | | 20 | ...............O=============== | |<--R(ANGLE)*3 21 | ............................... | | 22 | ............................... | | 23 | ............................... | |<--R(ANGLE)*2 24 | [4]...............................[6] | | 25 | ........................... | [1] X [2] [3] [4] [5] [6] | 26 | ..................... ||::::::|::::::::::::::::::::::::::::::::|<--R(ANGLE) 27 | ............... ||::::::|::::::::::::::::::::::::::::::::| 28 | ......... ||::::::|::::::::::::::::::::::::::::::::| 29 | ... 0---------------------------------------> 30 | [5] ANGLE 31 | 32 | R(ANGLE) is a function that returns the distance from the center of the polygon 33 | to the edge of the polygon at the given angle. 34 | 35 | NOTE: The Y-axis of the Unwrapped plot is warped such that R(ANGLE) lies on the 36 | same horizontal line for all values of ANGLE. 37 | 38 | 39 | DISTORTION WARNING: There is visible image distortion resulting from (1) subtle 40 | errors in the locations of the vertices and (2) perspective deformation of the 41 | image. It is a hard problem to undo perspective deformation, so this 42 | 'unwrapping' is only an approximation. 43 | 44 | """ 45 | 46 | from SimpleCV import * 47 | 48 | import pyglet 49 | from pyglet.gl import * 50 | from shader import Shader 51 | 52 | from parse import parse_frame 53 | 54 | def dist(p0,p1): 55 | """Distance between two 2D points.""" 56 | x0,y0 = p0 57 | x1,y1 = p1 58 | dx = x0-x1 59 | dy = y0-y1 60 | return math.sqrt(dx*dx + dy*dy) 61 | 62 | class Vertex: 63 | """A vertex of a polygon, containing calculated properties of that vertex.""" 64 | def __init__(self,point,center): 65 | self.point = point 66 | self.center = center 67 | self.radius = dist(point,center) 68 | self.rel = (point[0]-center[0], point[1]-center[1]) 69 | self.angle = math.atan2(self.rel[1], self.rel[0]) 70 | 71 | def copy(self): 72 | return Vertex(self.point, self.center) 73 | 74 | class TriangleProjector: 75 | """ 76 | Given two Vertex objects sharing a center point, this class determines the 77 | distance from that center point to the line segment of those two vertices 78 | along the direction of a given angle. 79 | 80 | To illustrate: 81 | 82 | vertex1 = V1 83 | vertex2 = V2 84 | center = C 85 | intersect = X 86 | 87 | V1 .............X........... V2 88 | ..........|........ 89 | .....|.... 90 | .|. 91 | C 92 | 93 | After computing the distance from C to X, we can compute the distance 94 | from the center to any point on V1->V2 given some angle: 95 | 96 | R(ANGLE) = |CX| / cos(ANGLE - angle(CX)) 97 | 98 | """ 99 | 100 | def __init__(self, vertex1, vertex2): 101 | 102 | # define angle bounds for this projector 103 | self.start_angle = vertex1.angle 104 | self.end_angle = vertex2.angle 105 | 106 | # short-hand names for each point 107 | point1 = vertex1.point 108 | point_center = vertex1.center 109 | point2 = vertex2.point 110 | 111 | # define the 2nd vertex and the center point relative to the 1st vertex 112 | rel_center = (point_center[0]-point1[0], point_center[1]-point1[1]) 113 | rel_point2 = (point2[0]-point1[0], point2[1]-point1[1]) 114 | 115 | # distance between the two vertices 116 | den = dist(rel_point2, (0,0)) 117 | 118 | # Rotate the center point such that the 1st and 2nd vertex will be 119 | # horizontal from each other. 120 | x = (rel_center[0]*rel_point2[0] + rel_center[1]*rel_point2[1]) / den 121 | y = (rel_center[1]*rel_point2[0] - rel_center[0]*rel_point2[1]) / den 122 | 123 | # On the line segment between the vertices, find the closest point 124 | # to the center point. 125 | # (This is just the x-component of our rotated center point, so we pick 126 | # the point that is 'x' distance along the line between vertex 1 and 127 | # 2.) 128 | intersect = ( 129 | int(point1[0] + x * rel_point2[0]/den), 130 | int(point1[1] + x * rel_point2[1]/den)) 131 | 132 | # The angle to the mid point. 133 | self.center_angle = math.atan2(intersect[1]-point_center[1], intersect[0]-point_center[0]) 134 | 135 | # Distance from the center point to the mid point on the line segment. 136 | self.center_dist = dist(intersect, point_center) 137 | 138 | def is_angle_inside(self, angle): 139 | """ 140 | Determines if the given angle is inside the range covered by our 141 | triangle. 142 | """ 143 | return self.start_angle <= angle and angle <= self.end_angle 144 | 145 | def angle_to_radius(self, angle): 146 | """ 147 | Returns the distance to the line segment created by our two vertices 148 | at the given angle. 149 | """ 150 | return self.center_dist / math.cos(abs(angle-self.center_angle)) 151 | 152 | class PolygonProjector: 153 | """ 154 | Given a list of points along a concave polygon, this class creates a list 155 | of TriangleProjector objects so that we can compute the distance between 156 | the center and the edge of the polygon given some angle. 157 | """ 158 | def __init__(self, center, points): 159 | 160 | vertices = [Vertex(v, center) for v in points] 161 | vertices.sort(key=lambda v: v.angle) 162 | 163 | # Make angle wrap from -pi to pi easier to deal with by copying each 164 | # endpoint vertex to the opposite side of the list with a lower or higher 165 | # but equivalent angle. 166 | v0 = vertices[0].copy() 167 | v0.angle += math.pi*2 168 | v1 = vertices[-1].copy() 169 | v1.angle -= math.pi*2 170 | vertices.insert(0,v1) 171 | vertices.append(v0) 172 | 173 | self.projectors = [TriangleProjector(vertices[i],vertices[i+1]) for i in xrange(len(vertices)-1)] 174 | self.vertices = vertices 175 | 176 | def angle_to_radius(self, angle): 177 | """ 178 | Get the distance from the center of this polygon to its edge at the 179 | given angle. 180 | """ 181 | for p in self.projectors: 182 | if p.is_angle_inside(angle): 183 | return p.angle_to_radius(angle) 184 | 185 | # We do not use custom vertex shaders, so this is the default one. 186 | vertex_shader = """ 187 | void main() { 188 | // transform the vertex position 189 | gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; 190 | // pass through the texture coordinate 191 | gl_TexCoord[0] = gl_MultiTexCoord0; 192 | } 193 | """ 194 | 195 | # This fragment shader is a compiled GPU program that is executed for 196 | # every pixel in our new image. 197 | # Input: gl_TexCoord[0].xy (0 <= x,y < 1 such that x+ right, y+ up) 198 | # Output: gl_FragColor 199 | # The global "uniform" variables are inputs shared by all pixels. 200 | fragment_shader = """ 201 | 202 | // the texture holding the original game image 203 | uniform sampler2D tex0; 204 | 205 | // size of texture in memory (padded to meet a power of 2) 206 | uniform vec2 actual_size; 207 | 208 | // size of the active region of the texture (excluding the padding) 209 | uniform vec2 region_size; 210 | 211 | // angle of each vertex 212 | // (14 is arbitrary length to allow for redundant vertices) 213 | uniform float angle_bounds[14]; 214 | 215 | // The radius and angle of each edge center 216 | // (13 is 1 less than the length of angle_bounds) 217 | uniform float radii[13]; 218 | uniform float angles[13]; 219 | 220 | // number of edges 221 | uniform int count; 222 | 223 | float PI = 3.14159265358979323846264; 224 | 225 | // get radius of the polygon at the given angle 226 | float get_radius(float angle) { 227 | int i; 228 | for (i=0; i 8 | 9 | Example: test.gif frame%04d.jpg -delay 1x30 -layers RemoveDups 10 | 11 | COMMENT LINE: 12 | 13 | Any line starting with '#' is ignored and can be used for comments. 14 | 15 | SEQUENCE LINE: 16 | 17 | 18 | 19 | Start frame: 20 | The frame to jump to. 21 | If frame < 0, then it will continue from the previous sequence. 22 | 23 | Duration: 24 | The number of frames to play from the start frame. Must be > 0. 25 | 26 | Repeat count: 27 | The number of times to play this sequence. Must be > 0. 28 | 29 | Example: 30 | 4 3 2 will generate frames 4 5 6 4 5 6 31 | 32 | Helpful Pause Patterns: 33 | 34 | (frame 1 N): 35 | Pauses at a frame for N frames. 36 | 37 | (-1 1 N): 38 | Pauses after the previous segment for N frames. 39 | """ 40 | 41 | import argparse 42 | import subprocess 43 | 44 | class GlitchPlayer: 45 | def __init__(self): 46 | self.i = 0 47 | 48 | def next_frames(self,seg): 49 | """ 50 | Continues the track with the given segment specification. 51 | """ 52 | start,duration,repeat = seg 53 | if duration <= 0: 54 | raise Exception("duration must be > 0: "+seg) 55 | if repeat <= 0: 56 | raise Exception("repeat must be > 0: "+seg) 57 | if start < 0: 58 | start = self.i 59 | self.i = start + duration 60 | return range(start,start+duration)*repeat 61 | 62 | def lines_to_segs(lines): 63 | segs = [] 64 | for line in lines: 65 | line = line.strip() 66 | if line and not line.startswith('#'): 67 | tokens = map(int,line.split()) 68 | if len(tokens) != 3: 69 | raise Exception('there must be 3 tokens specified per line: '+line) 70 | segs.append(tokens) 71 | return segs 72 | 73 | def segs_to_frames(segs): 74 | player = GlitchPlayer() 75 | frames = [] 76 | for s in segs: 77 | frames.extend(player.next_frames(s)) 78 | return frames 79 | 80 | def make_gif_from_frames(frames, format_str, output_name, args=[]): 81 | cmd = ['convert'] + args + [format_str % f for f in frames] + [output_name] 82 | print ' '.join(cmd) 83 | subprocess.call(cmd) 84 | 85 | def parse_file_header(line): 86 | tokens = line.split() 87 | return { 88 | 'output_name': tokens[0], 89 | 'format_str': tokens[1], 90 | 'args': tokens[2:], 91 | } 92 | 93 | def make_gif_from_file(f): 94 | for line in f: 95 | line = line.strip() 96 | if line and not line.startswith('#'): 97 | header = parse_file_header(line) 98 | break 99 | segs = lines_to_segs(f) 100 | frames = segs_to_frames(segs) 101 | if frames: 102 | print "making gif with %d frames" % len(frames) 103 | make_gif_from_frames(frames,**header) 104 | else: 105 | print "no frames to operate on." 106 | 107 | if __name__ == "__main__": 108 | desc = "Create a gif from the given glitch file. (See the file's __doc__ for more details.)" 109 | parser = argparse.ArgumentParser(description=desc) 110 | parser.add_argument('track_file', type=file) 111 | args = parser.parse_args() 112 | make_gif_from_file(args.track_file) 113 | -------------------------------------------------------------------------------- /img/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/header.png -------------------------------------------------------------------------------- /img/jelly.glitch: -------------------------------------------------------------------------------- 1 | # this file is intended as input to 'glitch.py' 2 | 3 | jelly.gif ../dump/jelly%04d.jpg -delay 1x30 -layers RemoveDups 4 | 5 | #671 20 1 6 | #900 30 1 7 | 1511 8 1 8 | -1 1 20 9 | 1456 3 4 10 | #-1 20 1 11 | 1524 1 20 12 | 1665 6 1 13 | -1 1 20 14 | 15 | #-1 20 1 16 | -------------------------------------------------------------------------------- /img/make_anim_diag.py: -------------------------------------------------------------------------------- 1 | # conversion from vim location (r,c) to canvas coord (x,y): 2 | # x = c-1 3 | # y = r-5 4 | template = """ 5 | [2] ________________________________________ 6 | ... | ^ 7 | ......... | | 8 | ..............X | | 9 | ................/.... | | 10 | ................../........ | | 11 | [3]........R(ANGLE)->./...........[1] | | 12 | ................../............ | |<--R(ANGLE)*4 13 | ................./\.<-ANGLE.... | | 14 | ................/ \........... | | 15 | ...............O=============== | |<--R(ANGLE)*3 16 | ............................... | | 17 | ............................... | | 18 | ............................... | |<--R(ANGLE)*2 19 | [4]...............................[6] | | 20 | ........................... | [1] X [2] [3] [4] [5] [6] | 21 | ..................... ||::::::|::::::::::::::::::::::::::::::::|<--R(ANGLE) 22 | ............... ||::::::|::::::::::::::::::::::::::::::::| 23 | ......... ||::::::|::::::::::::::::::::::::::::::::| 24 | ... 0---------------------------------------> 25 | [5] ANGLE 26 | """ 27 | 28 | title = """ 29 | ______ ____ ______ _____________________ 30 | / / / \/ / / / / . // . / . / . / __/ . // 31 | / / / /\ / ^^ / abs(x2-x1) 80 | if issteep: 81 | x1, y1 = y1, x1 82 | x2, y2 = y2, x2 83 | rev = False 84 | if x1 > x2: 85 | x1, x2 = x2, x1 86 | y1, y2 = y2, y1 87 | rev = True 88 | deltax = x2 - x1 89 | deltay = abs(y2-y1) 90 | error = int(deltax / 2) 91 | y = y1 92 | ystep = None 93 | if y1 < y2: 94 | ystep = 1 95 | else: 96 | ystep = -1 97 | for x in range(x1, x2 + 1): 98 | if issteep: 99 | points.append((y, x)) 100 | else: 101 | points.append((x, y)) 102 | error -= deltay 103 | if error < 0: 104 | y += ystep 105 | error += deltax 106 | # Reverse the list if the coordinates were reversed 107 | if rev: 108 | points.reverse() 109 | return points 110 | 111 | class AsciiCanvas: 112 | 113 | def __init__(self,w,h): 114 | self.w = w 115 | self.h = h 116 | self.canvas = [[' ']*w for i in xrange(h)] 117 | 118 | def clear(self): 119 | for y in xrange(self.h): 120 | for x in xrange(self.w): 121 | self.canvas[y][x] = ' ' 122 | 123 | def text(self, x, y, text, color='white'): 124 | for i,c in enumerate(text): 125 | x0 = x+i 126 | try: 127 | if x0 < self.w: 128 | self.canvas[y][x0] = colored(c, color) if color else c 129 | except IndexError: 130 | continue 131 | def get(self, x,y): 132 | return self.canvas[y][x] 133 | 134 | def line(self, p1, p2, c): 135 | pairs = get_line(p1, p2) 136 | for x,y in pairs: 137 | self.text(x,y,c) 138 | 139 | def display(self): 140 | print 141 | for line in self.canvas: 142 | print ' '+''.join(line) 143 | print 144 | 145 | def set_debug_border(self): 146 | for y in xrange(self.h): 147 | self.text(0,y,'|') 148 | self.text(self.w-1,y,'|') 149 | for x in xrange(self.w): 150 | self.text(x,0,'-') 151 | self.text(x,self.h-1,'-') 152 | 153 | def draw_rest(c,count=6,angle=0): 154 | # center of polygon 155 | c.text(18,10,'O') 156 | 157 | # inner polygon labels 158 | c.text(11,6,'R(ANGLE)->', color=r_color) 159 | c.text(23,8,'<-ANGLE', color=angle_color) 160 | 161 | # inner polygon angle degree marker 162 | for i in xrange(2): 163 | c.text(22-i,9-i,'\\') 164 | c.text(20,9,' ') 165 | 166 | # top unwrap plot border 167 | for i in xrange(40): 168 | c.text(43+i,0,'_') 169 | 170 | # left/right unwrap plot border 171 | for i in xrange(18): 172 | c.text(42,1+i,'|') 173 | c.text(83,1+i,'|') 174 | c.text(83,1,'^') 175 | 176 | # unwrap plot origin 177 | c.text(42,19,'0') 178 | 179 | # unwrap plot bottom border 180 | for i in xrange(39): 181 | c.text(43+i,19,'-') 182 | c.text(82,19,'>') 183 | 184 | # y axis labels 185 | c.text(84,7,'<--R(ANGLE)*4') 186 | c.text(84,10,'<--R(ANGLE)*3') 187 | c.text(84,13,'<--R(ANGLE)*2') 188 | c.text(84,16,'<--R(ANGLE)', color=r_color) 189 | c.text(60,20,'ANGLE', color=angle_color) 190 | 191 | 192 | # draw polygon area 193 | for i in xrange(3): 194 | for j in xrange(40): 195 | c.text(43+j,16+i,':') 196 | 197 | # draw vertices 198 | maxa = 40 199 | da = maxa/count 200 | base_a = int(angle/math.pi/2*maxa) 201 | angles = [base_a + int(float(maxa)/count * i) for i in xrange(count)] 202 | for i,a in enumerate(angles): 203 | x = 43 + a % maxa 204 | y = 15 205 | c.text(x-1,y,"[%d]"%(i+1),color=vert_color) 206 | 207 | # draw ray 208 | for i in xrange(3): 209 | c.text(50,16+i,'|', color=ray_color) 210 | c.text(43,16+i,'|') 211 | c.text(50,15,'X', color=ray_color) 212 | 213 | def draw_poly(c,count=6,angle=0): 214 | # center and radius 215 | cx = 18 216 | cy = 10 217 | r = 17 218 | 219 | da = 2*math.pi/count 220 | 221 | # calculate vertices 222 | verts = [] 223 | for i in xrange(count): 224 | a = angle+da*i 225 | dx = int(r*math.cos(-a)+0.5) 226 | dy = int(r*math.sin(-a)+0.5) 227 | verts.append((cx+dx, cy+dy/2)) 228 | 229 | # create the horizontal scan lines 230 | rows = {} 231 | for i in xrange(count): 232 | for x,y in get_line(verts[i], verts[(i+1)%count]): 233 | if y in rows: 234 | rows[y]['min'] = min(x, rows[y]['min']) 235 | rows[y]['max'] = max(x, rows[y]['max']) 236 | else: 237 | rows[y] = { 'min': x, 'max': x } 238 | 239 | # fill polygon by drawing scan lines 240 | for y,xs in rows.items(): 241 | for x in xrange(xs['min'],xs['max']+1): 242 | c.text(x,y,'.') 243 | 244 | # get polygon ray length 245 | raylen = 0 246 | rx = 19 247 | ry = 9 248 | for i in xrange(10): 249 | raylen += 1 250 | x = rx+i 251 | y = ry-i 252 | if c.get(x+1,y-1) == ' ': 253 | break 254 | 255 | # get polygon base angle length 256 | baselen = 0 257 | bx = 19 258 | by = 10 259 | for i in xrange(100): 260 | baselen += 1 261 | if c.get(bx+i,by) == ' ': 262 | break 263 | 264 | # inner polygon base angle line 265 | for i in xrange(baselen-1): 266 | c.text(bx+i,by,'=') 267 | 268 | # draw vertex labels 269 | for i in xrange(count): 270 | a = angle+da*i 271 | x = cx + int((r)*math.cos(-a)+0.5) 272 | y = cy + int((r)*math.sin(-a)+0.5)/2 273 | c.text(x-1,y,"[%d]"%(i+1), color=vert_color) 274 | 275 | # draw polygon ray 276 | for i in xrange(raylen): 277 | x = rx+i 278 | y = ry-i 279 | if i == raylen-1: 280 | c.text(x,y,'X',color=ray_color) 281 | else: 282 | c.text(x,y,'/',color=ray_color) 283 | 284 | def get_window_id(): 285 | """ 286 | source:http://stackoverflow.com/a/3552462/142317 287 | """ 288 | output = subprocess.check_output(['xprop', '-root']) 289 | for line in output.splitlines(): 290 | if '_NET_ACTIVE_WINDOW(WINDOW):' in line: 291 | return line.split()[4] 292 | 293 | if __name__ == "__main__": 294 | _id = get_window_id() 295 | 296 | c = AsciiCanvas(97,21) 297 | 298 | a = 0 299 | total_frames = 30 300 | frames_per_cycle = 30 301 | da = 2*math.pi/frames_per_cycle 302 | for i in xrange(total_frames): 303 | c.clear() 304 | #c.set_debug_border() 305 | draw_poly(c,count=6,angle=a) 306 | draw_rest(c,count=6,angle=a) 307 | os.system('clear') 308 | print paragraph1 309 | print plot_titles 310 | c.display() 311 | print paragraph2 312 | print 313 | print 314 | a -= da 315 | 316 | time.sleep(0.1) 317 | 318 | # use imagemagick to take a screenshot 319 | subprocess.call([ 320 | 'import', 321 | '-window',_id, 322 | 'frame%03d.png' % (i+2119), 323 | ]) 324 | -------------------------------------------------------------------------------- /img/parse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/parse.jpg -------------------------------------------------------------------------------- /img/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/screenshot.jpg -------------------------------------------------------------------------------- /img/termcolor.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # Copyright (c) 2008-2011 Volvox Development Team 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | # 22 | # Author: Konstantin Lepa 23 | 24 | """ANSII Color formatting for output in terminal.""" 25 | 26 | from __future__ import print_function 27 | import os 28 | 29 | 30 | __ALL__ = [ 'colored', 'cprint' ] 31 | 32 | VERSION = (1, 1, 0) 33 | 34 | ATTRIBUTES = dict( 35 | list(zip([ 36 | 'bold', 37 | 'dark', 38 | '', 39 | 'underline', 40 | 'blink', 41 | '', 42 | 'reverse', 43 | 'concealed' 44 | ], 45 | list(range(1, 9)) 46 | )) 47 | ) 48 | del ATTRIBUTES[''] 49 | 50 | 51 | HIGHLIGHTS = dict( 52 | list(zip([ 53 | 'on_grey', 54 | 'on_red', 55 | 'on_green', 56 | 'on_yellow', 57 | 'on_blue', 58 | 'on_magenta', 59 | 'on_cyan', 60 | 'on_white' 61 | ], 62 | list(range(40, 48)) 63 | )) 64 | ) 65 | 66 | 67 | COLORS = dict( 68 | list(zip([ 69 | 'grey', 70 | 'red', 71 | 'green', 72 | 'yellow', 73 | 'blue', 74 | 'magenta', 75 | 'cyan', 76 | 'white', 77 | ], 78 | list(range(30, 38)) 79 | )) 80 | ) 81 | 82 | 83 | RESET = '\033[0m' 84 | 85 | 86 | def colored(text, color=None, on_color=None, attrs=None): 87 | """Colorize text. 88 | 89 | Available text colors: 90 | red, green, yellow, blue, magenta, cyan, white. 91 | 92 | Available text highlights: 93 | on_red, on_green, on_yellow, on_blue, on_magenta, on_cyan, on_white. 94 | 95 | Available attributes: 96 | bold, dark, underline, blink, reverse, concealed. 97 | 98 | Example: 99 | colored('Hello, World!', 'red', 'on_grey', ['blue', 'blink']) 100 | colored('Hello, World!', 'green') 101 | """ 102 | if os.getenv('ANSI_COLORS_DISABLED') is None: 103 | fmt_str = '\033[%dm%s' 104 | if color is not None: 105 | text = fmt_str % (COLORS[color], text) 106 | 107 | if on_color is not None: 108 | text = fmt_str % (HIGHLIGHTS[on_color], text) 109 | 110 | if attrs is not None: 111 | for attr in attrs: 112 | text = fmt_str % (ATTRIBUTES[attr], text) 113 | 114 | text += RESET 115 | return text 116 | 117 | 118 | def cprint(text, color=None, on_color=None, attrs=None, **kwargs): 119 | """Print colorize text. 120 | 121 | It accepts arguments of print function. 122 | """ 123 | 124 | print((colored(text, color, on_color, attrs)), **kwargs) 125 | 126 | 127 | if __name__ == '__main__': 128 | print('Current terminal type: %s' % os.getenv('TERM')) 129 | print('Test basic colors:') 130 | cprint('Grey color', 'grey') 131 | cprint('Red color', 'red') 132 | cprint('Green color', 'green') 133 | cprint('Yellow color', 'yellow') 134 | cprint('Blue color', 'blue') 135 | cprint('Magenta color', 'magenta') 136 | cprint('Cyan color', 'cyan') 137 | cprint('White color', 'white') 138 | print(('-' * 78)) 139 | 140 | print('Test highlights:') 141 | cprint('On grey color', on_color='on_grey') 142 | cprint('On red color', on_color='on_red') 143 | cprint('On green color', on_color='on_green') 144 | cprint('On yellow color', on_color='on_yellow') 145 | cprint('On blue color', on_color='on_blue') 146 | cprint('On magenta color', on_color='on_magenta') 147 | cprint('On cyan color', on_color='on_cyan') 148 | cprint('On white color', color='grey', on_color='on_white') 149 | print('-' * 78) 150 | 151 | print('Test attributes:') 152 | cprint('Bold grey color', 'grey', attrs=['bold']) 153 | cprint('Dark red color', 'red', attrs=['dark']) 154 | cprint('Underline green color', 'green', attrs=['underline']) 155 | cprint('Blink yellow color', 'yellow', attrs=['blink']) 156 | cprint('Reversed blue color', 'blue', attrs=['reverse']) 157 | cprint('Concealed Magenta color', 'magenta', attrs=['concealed']) 158 | cprint('Bold underline reverse cyan color', 'cyan', 159 | attrs=['bold', 'underline', 'reverse']) 160 | cprint('Dark blink concealed white color', 'white', 161 | attrs=['dark', 'blink', 'concealed']) 162 | print(('-' * 78)) 163 | 164 | print('Test mixing:') 165 | cprint('Underline red on grey color', 'red', 'on_grey', 166 | ['underline']) 167 | cprint('Reversed green on red color', 'green', 'on_red', ['reverse']) 168 | 169 | -------------------------------------------------------------------------------- /img/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/test.jpg -------------------------------------------------------------------------------- /img/title_howitworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/title_howitworks.png -------------------------------------------------------------------------------- /img/title_makingvideos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/title_makingvideos.png -------------------------------------------------------------------------------- /img/unwrap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/unwrap.jpg -------------------------------------------------------------------------------- /img/vid_comp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/vid_comp.jpg -------------------------------------------------------------------------------- /img/vid_unwrap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/img/vid_unwrap.jpg -------------------------------------------------------------------------------- /unwrap_video.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shows the given Super Hexagon video next to an unwrapped* version of it. 3 | 4 | *unwrapped means the walls fall top-to-bottom instead of out-to-in. 5 | """ 6 | 7 | import sys 8 | import os 9 | import argparse 10 | 11 | # SimpleCV image processing and computer vision library 12 | import SimpleCV as scv 13 | 14 | # Custom "Super Hexagon" parsing library 15 | from code.parse import parse_frame 16 | from code.unwrap import start_unwrap_window, Unwrapper 17 | 18 | class VideoDone(Exception): 19 | """ 20 | Used to signal when video is done processing. We need an exception for 21 | this since there seems to be no other way to handle this smoothly. 22 | """ 23 | pass 24 | 25 | def unwrap_video(video_path, start_frame=0, stop_frame=-1, dump_dir=None, print_log=True): 26 | """ 27 | Shows the given Super Hexagon video next to an unwrapped* version of it. 28 | 29 | *unwrapped means the walls fall top-to-bottom instead of out-to-in. 30 | 31 | video_path = path to the Super Hexagon video 32 | start_frame = start at this frame in the video 33 | stop_frame = stop at this frame in the video 34 | frames_dir = directory to dump the frames in 35 | """ 36 | 37 | def log(*args): 38 | """ 39 | Helper function to display messages on the same line. 40 | """ 41 | if print_log: 42 | sys.stdout.write('\r' + ' '.join(map(str, args)).ljust(60)) 43 | sys.stdout.flush() 44 | 45 | # Create virtual camera for reading the video. 46 | video = scv.VirtualCamera(video_path, 'video') 47 | 48 | # Create frames output directory. 49 | if dump_dir: 50 | if not os.path.exists(dump_dir): 51 | os.makedirs(dump_dir) 52 | 53 | # Skip to the starting frame. 54 | i = 0 55 | while i < start_frame: 56 | video.getImage() 57 | log("skipping frame:",i) 58 | i += 1 59 | 60 | # create unwrapper 61 | unwrapper = Unwrapper() 62 | 63 | # get first image so we can correctly size the gl window 64 | img = video.getImage() 65 | w,h = img.size() 66 | 67 | # create state object for the "on_draw" callback 68 | self = { 69 | "i": i, 70 | "total": 0, 71 | "first_img": img, 72 | } 73 | 74 | def get_dump_name(prefix): 75 | """ 76 | Get the filename of the dumped frame. 77 | """ 78 | return "%s/%s%04d.jpg" % (dump_dir, prefix, self["i"]) 79 | 80 | def on_draw(): 81 | """ 82 | Our main processing loop that is called by the OpenGL window draw event. 83 | """ 84 | 85 | # get first image or read next image 86 | img = self["first_img"] 87 | if img: 88 | self["first_img"] = None 89 | else: 90 | img = video.getImage() 91 | 92 | # Try to show the retrieved image in SimpleCV's own window. If it 93 | # fails, then we reached the end of the video and can raise the custom 94 | # VideoDone exception. 95 | try: 96 | img.show() 97 | except: 98 | raise VideoDone() 99 | 100 | # Print log message 101 | if dump_dir: 102 | log('processing/dumping frame:', self["i"],'(%d fps)' % unwrapper.get_fps()) 103 | else: 104 | log('processing frame:', self["i"],'(%d fps)' % unwrapper.get_fps()) 105 | 106 | # Get the features out of the image. 107 | frame = parse_frame(img) 108 | 109 | # Create file names of the dumped frames. 110 | orig_name = get_dump_name('orig') 111 | unwrap_name = get_dump_name('unwrap') 112 | 113 | # Generate and show the unwrapped image. 114 | if frame: 115 | if not dump_dir: 116 | # Write the image to a temp file so pyglet can read the texture. 117 | img.save('tmp.jpg') 118 | unwrapper.update('tmp.jpg', frame) 119 | unwrapper.draw() 120 | else: 121 | # Dump the original frame. 122 | img.save(orig_name) 123 | 124 | # Update the unwrapper and draw the unwrapped frame. 125 | unwrapper.update(orig_name, frame) 126 | unwrapper.draw() 127 | 128 | # Dump the unwrapped frame. 129 | unwrapper.save_image(unwrap_name) 130 | else: 131 | if dump_dir: 132 | # dump the current images if the parsing failed 133 | img.save(orig_name) 134 | unwrapper.save_image(unwrap_name) 135 | 136 | self["total"] += 1 137 | 138 | # Stop processing if we reached the last requested frame. 139 | if self["i"] == stop_frame: 140 | raise VideoDone() 141 | 142 | self["i"] += 1 143 | 144 | # Run the opengl window until the last video frame is processed. 145 | try: 146 | start_unwrap_window(w,h,on_draw) 147 | except VideoDone: 148 | pass 149 | 150 | # Try to remove the temporary image file. 151 | if not dump_dir: 152 | try: 153 | os.remove('tmp.jpg') 154 | except OSError: 155 | pass 156 | 157 | # Print final log message. 158 | if dump_dir: 159 | log('Dumped',self["total"],'frames to "%s".' % dump_dir) 160 | else: 161 | log(self["total"],'frames processed.') 162 | 163 | # Append new line so the terminal can continue after our log line. 164 | if print_log: 165 | print 166 | 167 | if __name__ == "__main__": 168 | 169 | # Create argument parser 170 | parser = argparse.ArgumentParser( 171 | description=__doc__, 172 | formatter_class=argparse.RawTextHelpFormatter) 173 | #usage="%(prog)s [options] video") 174 | parser.add_argument('video', help='path to video of super hexagon') 175 | parser.add_argument('--out', metavar='DIR', help='dump frames into this directory') 176 | parser.add_argument('--start', metavar='N', type=int, help='start at this frame of the video') 177 | parser.add_argument('--stop', metavar='N', type=int, help='stop at this frame of the video') 178 | args = parser.parse_args() 179 | 180 | # Create optional args from those parsed 181 | opts = {} 182 | if args.out: 183 | opts['dump_dir'] = args.out 184 | if args.start: 185 | opts['start_frame'] = args.start 186 | if args.stop: 187 | opts['stop_frame'] = args.stop 188 | 189 | # Unwrap video 190 | unwrap_video(args.video, **opts) 191 | -------------------------------------------------------------------------------- /vid/README.md: -------------------------------------------------------------------------------- 1 | ![Making Videos](../img/title_makingvideos.png) 2 | 3 | The following examples use the included video "trailer.mp4", the [Super Hexagon 4 | Trailer](http://www.youtube.com/watch?v=2sz0mI_6tLQ). 5 | 6 | (Make sure you run the commands from the project's root directory, and install 7 | the FFmpeg and ImageMagick commands.) 8 | 9 | To make a __video of the unwrapped frames__, first dump the frames with the script: 10 | ``` 11 | python unwrap_video.py vid/trailer.mp4 --out frames 12 | ``` 13 | 14 | Look for the framerate of the source video in the following output: 15 | ``` 16 | ffmpeg -i vid/trailer.mp4 17 | ``` 18 | 19 | Extract the sound from source video: 20 | ``` 21 | ffmpeg -i vid/trailer.mp4 vid/trailer.mp3 22 | ``` 23 | 24 | Finally, encode the video from unwrapped frames, assuming a framerate of 30: 25 | ``` 26 | ffmpeg -r 30 -i frames/unwrap%04d.jpg -i vid/trailer.mp3 -vcodec libx264 -acodec copy vid/unwrap.avi 27 | ``` 28 | 29 | You should get a video that looks like the following (click to watch): 30 | 31 | [![unwrapped vid screenshot](../img/vid_unwrap.jpg)](https://vimeo.com/78922670) 32 | 33 | To make a __composite video__ that displays both the original and unwrapped on 34 | top of each other, first create the composite frames: 35 | ``` 36 | for f in frames/orig*.jpg; do \ 37 | echo "creating ${f/orig/comp}" 38 | montage -tile 1x2 -geometry +0+0 ${f/orig/unwrap} $f ${f/orig/comp} 39 | done 40 | ``` 41 | 42 | Then, encode the video as before but with the new composite frames: 43 | ``` 44 | ffmpeg -r 30 -i frames/comp%04d.jpg -i vid/trailer.mp3 -vcodec libx264 -acodec copy vid/comp.avi 45 | ``` 46 | 47 | You should get a video that looks like the following (click to watch): 48 | 49 | [![composite vid screenshot](../img/vid_comp.jpg)](https://vimeo.com/78922669) 50 | 51 | 52 | -------------------------------------------------------------------------------- /vid/trailer.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunlebron/super-hexagon-unwrapper/48d0a916674159cf49448a60f5c30901c57ac6c8/vid/trailer.mp4 --------------------------------------------------------------------------------