├── .gitignore ├── LICENSE ├── README ├── depixel ├── __init__.py ├── bspline.py ├── depixeler.py ├── io_data.py ├── io_png.py ├── io_svg.py ├── scripts │ ├── __init__.py │ ├── depixel_png.py │ └── export_test_image.py └── tests │ ├── __init__.py │ ├── test_bspline.py │ └── test_depixeler.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Jeremy Thurgood 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | This is an implementation of the "Depixelizing Pixel Art" algorithm: 2 | 3 | http://research.microsoft.com/en-us/um/people/kopf/pixelart/ 4 | 5 | This code is provided under the MIT license, the full text of which is in the 6 | LICENSE file. As far as I know, the algorithm is not covered by any patents or 7 | restrictive copyright. 8 | 9 | This is very much a work in progress at present. My primary aim is to build a 10 | tool to generate outline fonts from low-resolution bitmap fonts, but that isn't 11 | ready yet. 12 | 13 | So far I have the basic pixel grid deformation, shape outline extraction and 14 | somewhat wonky curve smoothing implemented and I can write representations of 15 | the intermediate steps to PNG and SVG. (The curve smoothing is pretty 16 | experimental, and probably full of exciting bugs. It also operates on each 17 | shape individually, so there's weirdness along the edges.) 18 | 19 | There is a handy script to depixel PNGs in the `depixel/scripts` directory, and 20 | there are unit tests covering some of the code. 21 | 22 | I like to keep dependencies small and light, but there are some useful bits 23 | I've pulled in (or will pull in) to make life easier: 24 | 25 | * I use `networkx` to do the graph stuff, because implementing it myself was 26 | getting messy. Switching to a more special-purpose graph library might give 27 | some performance benefits, but that's fairly low down my priority list at 28 | present. 29 | 30 | * I use `pypng` to do the PNG reading and writing, but that's isolated in the 31 | things that need it and the actual depixeling code works fine without it. 32 | 33 | * I use `svgwrite` to do the SVG writing, but that's isolated in the things 34 | that need it and the actual depixeling code works fine without it. 35 | 36 | * I'm probably going to need `bdflib` once I start working with fonts. Like 37 | the PNG stuff, I'll restrict its use to the places that need it. Since BDF 38 | is a fairly simple format and this library doesn't play nice with pip, I may 39 | rewrite the bits I need here as well. 40 | 41 | * I use Twisted's trial testrunner to run the tests, but that isn't required 42 | as long as you're happy to figure out how to discover and run the test cases 43 | yourself. (I think nose or something should work as well. I just like 44 | Twisted, and I had some editor hooks set up to use trial for other 45 | projects.) 46 | -------------------------------------------------------------------------------- /depixel/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerith/depixel/d2012015043ab9a530919f2134f1a1d15d3960ab/depixel/__init__.py -------------------------------------------------------------------------------- /depixel/bspline.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: depixel.tests.test_bspline -*- 2 | 3 | """ 4 | This is a limited quadratic B-spline library. 5 | 6 | The mathematics mostly comes from some excellent course notes on the web: 7 | 8 | http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/ 9 | 10 | More specifically, De Boor's Algorithm is at: 11 | 12 | http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/B-spline/de-Boor.html 13 | 14 | Errors are likely due to my lack of understanding rather than any deficiency in 15 | the source material. I don't completely understand the underlying theory, so I 16 | may have done something silly. However, my tests seem to do the right thing. 17 | """ 18 | 19 | import random 20 | from math import sqrt, sin, cos, pi 21 | 22 | 23 | class Point(object): 24 | """More convenient than using tuples everywhere. 25 | 26 | This implementation uses complex numbers under the hood, but that shouldn't 27 | really matter anywhere else. 28 | """ 29 | def __init__(self, value): 30 | if isinstance(value, complex): 31 | self.value = value 32 | elif isinstance(value, (tuple, list)): 33 | self.value = value[0] + value[1] * 1j 34 | elif isinstance(value, Point): 35 | self.value = value.value 36 | else: 37 | raise ValueError("Invalid value for Point: %r" % (value,)) 38 | 39 | def __str__(self): 40 | return "" % (self.x, self.y) 41 | 42 | def __repr__(self): 43 | return str(self) 44 | 45 | @property 46 | def x(self): 47 | return self.value.real 48 | 49 | @property 50 | def y(self): 51 | return self.value.imag 52 | 53 | @property 54 | def tuple(self): 55 | return (self.x, self.y) 56 | 57 | def _op(self, op, other): 58 | if isinstance(other, Point): 59 | other = other.value 60 | return Point(getattr(self.value, op)(other)) 61 | 62 | def __eq__(self, other): 63 | try: 64 | other = Point(other).value 65 | except ValueError: 66 | pass 67 | return self.value.__eq__(other) 68 | 69 | def __add__(self, other): 70 | return self._op('__add__', other) 71 | 72 | def __radd__(self, other): 73 | return self._op('__radd__', other) 74 | 75 | def __sub__(self, other): 76 | return self._op('__sub__', other) 77 | 78 | def __rsub__(self, other): 79 | return self._op('__rsub__', other) 80 | 81 | def __mul__(self, other): 82 | return self._op('__mul__', other) 83 | 84 | def __rmul__(self, other): 85 | return self._op('__rmul__', other) 86 | 87 | def __div__(self, other): 88 | return self._op('__div__', other) 89 | 90 | def __rdiv__(self, other): 91 | return self._op('__rdiv__', other) 92 | 93 | def __abs__(self): 94 | return abs(self.value) 95 | 96 | def round(self, places=5): 97 | return Point((round(self.x, places), round(self.y, places))) 98 | 99 | 100 | class BSpline(object): 101 | """ 102 | This is made out of mathematics. You have been warned. 103 | 104 | A B-spline has: 105 | * n + 1 control points 106 | * m + 1 knots 107 | * degree p 108 | * m = n + p + 1 109 | """ 110 | def __init__(self, knots, points, degree=None): 111 | self.knots = tuple(knots) 112 | self._points = [Point(p) for p in points] 113 | expected_degree = len(self.knots) - len(self._points) - 1 114 | if degree is None: 115 | degree = expected_degree 116 | if degree != expected_degree: 117 | raise ValueError("Expected degree %s, got %s." % ( 118 | expected_degree, degree)) 119 | self.degree = degree 120 | self._reset_cache() 121 | 122 | def _reset_cache(self): 123 | self._cache = {} 124 | 125 | def move_point(self, i, value): 126 | self._points[i] = value 127 | self._reset_cache() 128 | 129 | def __str__(self): 130 | return "<%s degree=%s, points=%s, knots=%s>" % ( 131 | type(self).__name__, 132 | self.degree, len(self.points), len(self.knots)) 133 | 134 | def copy(self): 135 | return type(self)(self.knots, self.points, self.degree) 136 | 137 | @property 138 | def domain(self): 139 | return (self.knots[self.degree], 140 | self.knots[len(self.knots) - self.degree - 1]) 141 | 142 | @property 143 | def points(self): 144 | return tuple(self._points) 145 | 146 | @property 147 | def useful_points(self): 148 | return self.points 149 | 150 | def __call__(self, u): 151 | """ 152 | De Boor's Algorithm. Made out of more maths. 153 | """ 154 | s = len([uk for uk in self.knots if uk == u]) 155 | for k, uk in enumerate(self.knots): 156 | if uk >= u: 157 | break 158 | if s == 0: 159 | k -= 1 160 | if self.degree == 0: 161 | if k == len(self.points): 162 | k -= 1 163 | return self.points[k] 164 | ps = [dict(zip(range(k - self.degree, k - s + 1), 165 | self.points[k - self.degree:k - s + 1]))] 166 | 167 | for r in range(1, self.degree - s + 1): 168 | ps.append({}) 169 | for i in range(k - self.degree + r, k - s + 1): 170 | a = (u - self.knots[i]) / (self.knots[i + self.degree - r + 1] 171 | - self.knots[i]) 172 | ps[r][i] = (1 - a) * ps[r - 1][i - 1] + a * ps[r - 1][i] 173 | return ps[-1][k - s] 174 | 175 | def quadratic_bezier_segments(self): 176 | """ 177 | Extract a sequence of quadratic Bezier curves making up this spline. 178 | 179 | NOTE: This assumes our spline is quadratic. 180 | """ 181 | assert self.degree == 2 182 | control_points = self.points[1:-1] 183 | on_curve_points = [self(u) for u in self.knots[2:-2]] 184 | ocp0 = on_curve_points[0] 185 | for cp, ocp1 in zip(control_points, on_curve_points[1:]): 186 | yield (ocp0.tuple, cp.tuple, ocp1.tuple) 187 | ocp0 = ocp1 188 | 189 | def derivative(self): 190 | """ 191 | Take the derivative. 192 | """ 193 | cached = self._cache.get('derivative') 194 | if cached: 195 | return cached 196 | 197 | new_points = [] 198 | p = self.degree 199 | for i in range(0, len(self.points) - 1): 200 | coeff = p / (self.knots[i + 1 + p] - self.knots[i + 1]) 201 | new_points.append(coeff * (self.points[i + 1] - self.points[i])) 202 | 203 | cached = BSpline(self.knots[1:-1], new_points, p - 1) 204 | self._cache['derivative'] = cached 205 | return cached 206 | 207 | def _clamp_domain(self, value): 208 | return max(self.domain[0], min(self.domain[1], value)) 209 | 210 | def _get_span(self, index): 211 | return (self._clamp_domain(self.knots[index]), 212 | self._clamp_domain(self.knots[index + 1])) 213 | 214 | def _get_point_spans(self, index): 215 | return [self._get_span(index + i) for i in range(self.degree)] 216 | 217 | def integrate_over_span(self, func, span, intervals): 218 | if span[0] == span[1]: 219 | return 0 220 | 221 | interval = (span[1] - span[0]) / intervals 222 | result = (func(span[0]) + func(span[1])) / 2 223 | for i in range(1, intervals): 224 | result += func(span[0] + i * interval) 225 | result *= interval 226 | 227 | return result 228 | 229 | def integrate_for(self, index, func, intervals): 230 | spans_ = self._get_point_spans(index) 231 | spans = [span for span in spans_ if span[0] != span[1]] 232 | return sum(self.integrate_over_span(func, span, intervals) 233 | for span in spans) 234 | 235 | def curvature(self, u): 236 | d1 = self.derivative()(u) 237 | d2 = self.derivative().derivative()(u) 238 | num = d1.x * d2.y - d1.y * d2.x 239 | den = sqrt(d1.x ** 2 + d1.y ** 2) ** 3 240 | if den == 0: 241 | return 0 242 | return abs(num / den) 243 | 244 | def curvature_energy(self, index, intervals_per_span): 245 | return self.integrate_for(index, self.curvature, intervals_per_span) 246 | 247 | def reversed(self): 248 | return type(self)( 249 | (1 - k for k in reversed(self.knots)), reversed(self._points), 250 | self.degree) 251 | 252 | 253 | class ClosedBSpline(BSpline): 254 | def __init__(self, knots, points, degree=None): 255 | super(ClosedBSpline, self).__init__(knots, points, degree) 256 | self._unwrapped_len = len(self._points) - self.degree 257 | self._check_wrapped() 258 | 259 | def _check_wrapped(self): 260 | if self._points[:self.degree] != self._points[-self.degree:]: 261 | raise ValueError( 262 | "Points not wrapped at degree %s." % (self.degree,)) 263 | 264 | def move_point(self, index, value): 265 | if not 0 <= index < len(self._points): 266 | raise IndexError(index) 267 | index = index % self._unwrapped_len 268 | super(ClosedBSpline, self).move_point(index, value) 269 | if index < self.degree: 270 | super(ClosedBSpline, self).move_point( 271 | index + self._unwrapped_len, value) 272 | 273 | @property 274 | def useful_points(self): 275 | return self.points[:-self.degree] 276 | 277 | def _get_span(self, index): 278 | span = lambda i: (self.knots[i], self.knots[i + 1]) 279 | d0, d1 = span(index) 280 | if d0 < self.domain[0]: 281 | d0, d1 = span(index + len(self.points) - self.degree) 282 | elif d1 > self.domain[1]: 283 | d0, d1 = span(index + self.degree - len(self.points)) 284 | return self._clamp_domain(d0), self._clamp_domain(d1) 285 | 286 | 287 | def polyline_to_closed_bspline(path, degree=2): 288 | """ 289 | Make a closed B-spline from a path through some nodes. 290 | """ 291 | 292 | points = path + path[:degree] 293 | m = len(points) + degree 294 | knots = [float(i) / m for i in range(m + 1)] 295 | 296 | return ClosedBSpline(knots, points, degree) 297 | 298 | 299 | def magnitude(point): 300 | return sqrt(point[0] ** 2 + point[2] ** 2) 301 | 302 | 303 | class SplineSmoother(object): 304 | INTERVALS_PER_SPAN = 20 305 | POINT_GUESSES = 20 306 | GUESS_OFFSET = 0.05 307 | ITERATIONS = 20 308 | POSITIONAL_ENERGY_MULTIPLIER = 1 309 | 310 | # INTERVALS_PER_SPAN = 5 311 | # POINT_GUESSES = 1 312 | # ITERATIONS = 1 313 | 314 | def __init__(self, spline): 315 | self.orig = spline 316 | self.spline = spline.copy() 317 | 318 | def _e_curvature(self, index): 319 | return self.spline.curvature_energy(index, self.INTERVALS_PER_SPAN) 320 | 321 | def _e_positional(self, index): 322 | orig = self.orig.points[index] 323 | point = self.spline.points[index] 324 | e_positional = abs(point - orig) ** 4 325 | return e_positional * self.POSITIONAL_ENERGY_MULTIPLIER 326 | 327 | def point_energy(self, index): 328 | e_curvature = self._e_curvature(index) 329 | e_positional = self._e_positional(index) 330 | return e_positional + e_curvature 331 | 332 | def _rand(self): 333 | offset = random.random() * self.GUESS_OFFSET 334 | angle = random.random() * 2 * pi 335 | return offset * Point((cos(angle), sin(angle))) 336 | 337 | def smooth_point(self, index, start): 338 | energies = [(self.point_energy(index), start)] 339 | for _ in range(self.POINT_GUESSES): 340 | point = start + self._rand() 341 | self.spline.move_point(index, point) 342 | energies.append((self.point_energy(index), point)) 343 | self.spline.move_point(index, min(energies)[1]) 344 | 345 | def smooth(self): 346 | for _it in range(self.ITERATIONS): 347 | # print("IT:", _it) 348 | for i, point in enumerate(self.spline.useful_points): 349 | self.smooth_point(i, point) 350 | 351 | 352 | def smooth_spline(spline): 353 | smoother = SplineSmoother(spline) 354 | smoother.smooth() 355 | return smoother.spline 356 | -------------------------------------------------------------------------------- /depixel/depixeler.py: -------------------------------------------------------------------------------- 1 | # -*- test-case-name: depixel.tests.test_depixeler -*- 2 | 3 | """ 4 | An implementation of the Depixelizing Pixel Art algorithm. 5 | 6 | The paper can be found online at: 7 | http://research.microsoft.com/en-us/um/people/kopf/pixelart/ 8 | """ 9 | 10 | from math import sqrt 11 | 12 | import networkx as nx 13 | 14 | from depixel import bspline 15 | 16 | 17 | def gen_coords(size): 18 | for y in range(size[1]): 19 | for x in range(size[0]): 20 | yield (x, y) 21 | 22 | 23 | def within_bounds(coord, size, offset=(0, 0)): 24 | x, y = map(sum, zip(coord, offset)) 25 | size_x, size_y = size 26 | return (0 <= x < size_x and 0 <= y < size_y) 27 | 28 | 29 | def cn_edge(edge): 30 | return tuple(sorted(edge[:2])) 31 | 32 | 33 | def distance(p0, p1): 34 | return sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2) 35 | 36 | 37 | def gradient(p0, p1): 38 | # Assume the constant below is big enough. Bleh. 39 | dx = p1[0] - p0[0] 40 | dy = p1[1] - p0[1] 41 | if dx == 0: 42 | return dy * 99999999999999 43 | return 1.0 * dy / dx 44 | 45 | 46 | def remove_from_set(things, thing): 47 | things.add(thing) 48 | things.remove(thing) 49 | 50 | 51 | class DiagonalResolutionHeuristics(object): 52 | SPARSE_WINDOW_SIZE = (8, 8) 53 | 54 | def __init__(self, pixel_graph): 55 | self.pixel_graph = pixel_graph 56 | 57 | def sparse_window_offset(self, edge): 58 | return ( 59 | self.SPARSE_WINDOW_SIZE[0] / 2 - 1 - min((edge[0][0], edge[1][0])), 60 | self.SPARSE_WINDOW_SIZE[1] / 2 - 1 - min((edge[0][1], edge[1][1]))) 61 | 62 | def apply(self, blocks): 63 | raise NotImplementedError() 64 | 65 | 66 | class FullyConnectedHeuristics(DiagonalResolutionHeuristics): 67 | def apply(self, diagonal_pairs): 68 | """ 69 | Iterate over the set of ambiguous diagonals and resolve them. 70 | """ 71 | for edges in diagonal_pairs: 72 | self.weight_diagonals(*edges) 73 | 74 | for edges in diagonal_pairs: 75 | min_weight = min(e[2]['h_weight'] for e in edges) 76 | for edge in edges: 77 | if edge[2]['h_weight'] == min_weight: 78 | self.pixel_graph.remove_edge(*edge[:2]) 79 | else: 80 | edge[2].pop('h_weight') 81 | 82 | def weight_diagonals(self, edge1, edge2): 83 | """ 84 | Apply heuristics to ambiguous diagonals. 85 | """ 86 | for edge in (edge1, edge2): 87 | self.weight_diagonal(edge) 88 | 89 | def weight_diagonal(self, edge): 90 | """ 91 | Apply heuristics to an ambiguous diagonal. 92 | """ 93 | weights = [ 94 | self.weight_curve(edge), 95 | self.weight_sparse(edge), 96 | self.weight_island(edge), 97 | ] 98 | edge[2]['h_weight'] = sum(weights) 99 | 100 | def weight_curve(self, edge): 101 | """ 102 | Weight diagonals based on curve length. 103 | 104 | Edges that are part of long single-pixel-wide features are 105 | more likely to be important. 106 | """ 107 | seen_edges = set([cn_edge(edge)]) 108 | nodes = list(edge[:2]) 109 | 110 | while nodes: 111 | node = nodes.pop() 112 | edges = self.pixel_graph.edges(node, data=True) 113 | if len(edges) != 2: 114 | # This node is not part of a curve 115 | continue 116 | for edge in edges: 117 | edge = cn_edge(edge) 118 | if edge not in seen_edges: 119 | seen_edges.add(edge) 120 | nodes.extend(n for n in edge if n != node) 121 | return len(seen_edges) 122 | 123 | def weight_sparse(self, edge): 124 | """ 125 | Weight diagonals based on feature sparseness. 126 | 127 | Sparse features are more likely to be seen as "foreground" 128 | rather than "background", and are therefore likely to be more 129 | important. 130 | """ 131 | 132 | nodes = list(edge[:2]) 133 | seen_nodes = set(nodes) 134 | offset = self.sparse_window_offset(edge) 135 | 136 | while nodes: 137 | node = nodes.pop() 138 | for n in self.pixel_graph.neighbors(node): 139 | if n in seen_nodes: 140 | continue 141 | if within_bounds(n, self.SPARSE_WINDOW_SIZE, offset=offset): 142 | seen_nodes.add(n) 143 | nodes.append(n) 144 | 145 | return -len(seen_nodes) 146 | 147 | def weight_island(self, edge): 148 | """ 149 | Weight diagonals connected to "islands". 150 | 151 | Single pixels connected to nothing except the edge being 152 | examined are likely to be more important. 153 | """ 154 | if (len(self.pixel_graph[edge[0]]) == 1 155 | or len(self.pixel_graph[edge[1]]) == 1): 156 | return 5 157 | return 0 158 | 159 | 160 | class IterativeFinalShapeHeuristics(DiagonalResolutionHeuristics): 161 | def apply(self, diagonal_pairs): 162 | """ 163 | Iterate over the set of ambiguous diagonals and resolve them. 164 | """ 165 | new_pairs = [] 166 | 167 | for edges in diagonal_pairs: 168 | for edge in edges: 169 | edge[2]['ambiguous'] = True 170 | 171 | for edges in diagonal_pairs: 172 | removals = self.weight_diagonals(*edges) 173 | if removals is None: 174 | # Nothing to remove, so we're still ambiguous. 175 | new_pairs.append(edges) 176 | continue 177 | 178 | for edge in edges: 179 | if edge in removals: 180 | # Remove this edge 181 | self.pixel_graph.remove_edge(edge[0], edge[1]) 182 | else: 183 | # Clean up other edges 184 | edge[2].pop('h_weight') 185 | edge[2].pop('ambiguous') 186 | 187 | # Reiterate if necessary. 188 | if not new_pairs: 189 | # Nothing more to do, let's go home. 190 | return 191 | elif new_pairs == diagonal_pairs: 192 | # No more unambiguous pairs. 193 | # TODO: Handle this gracefully. 194 | raise ValueError("No more unambiguous blocks.") 195 | else: 196 | # Try again. 197 | self.apply(new_pairs) 198 | 199 | def weight_diagonals(self, edge1, edge2): 200 | """ 201 | Apply heuristics to ambiguous diagonals. 202 | """ 203 | for edge in (edge1, edge2): 204 | self.weight_diagonal(edge) 205 | 206 | favour1 = edge1[2]['h_weight'][1] - edge2[2]['h_weight'][0] 207 | favour2 = edge1[2]['h_weight'][0] - edge2[2]['h_weight'][1] 208 | 209 | if favour1 == 0 and favour2 == 0: 210 | # Unambiguous, remove both. 211 | return (edge1, edge2) 212 | if favour1 >= 0 and favour2 >= 0: 213 | # Unambiguous, edge1 wins. 214 | return (edge2,) 215 | if favour1 <= 0 and favour2 <= 0: 216 | # Unambiguous, edge2 wins. 217 | return (edge1,) 218 | # We have an ambiguous result. 219 | return None 220 | 221 | def weight_diagonal(self, edge): 222 | """ 223 | Apply heuristics to an ambiguous diagonal. 224 | """ 225 | weights = [ 226 | self.weight_curve(edge), 227 | self.weight_sparse(edge), 228 | self.weight_island(edge), 229 | ] 230 | edge[2]['h_weight'] = tuple(sum(w) for w in zip(*weights)) 231 | 232 | def weight_curve(self, edge): 233 | """ 234 | Weight diagonals based on curve length. 235 | 236 | Edges that are part of long single-pixel-wide features are 237 | more likely to be important. 238 | """ 239 | seen_edges = set([cn_edge(edge)]) 240 | nodes = list(edge[:2]) 241 | 242 | values = list(self._weight_curve(nodes, seen_edges)) 243 | retvals = (min(values), max(values)) 244 | return retvals 245 | 246 | def _weight_curve(self, nodes, seen_edges): 247 | while nodes: 248 | node = nodes.pop() 249 | edges = self.pixel_graph.edges(node, data=True) 250 | if len(edges) != 2: 251 | # This node is not part of a curve 252 | continue 253 | for edge in edges: 254 | ambiguous = ('ambiguous' in edge[2]) 255 | edge = cn_edge(edge) 256 | if edge not in seen_edges: 257 | seen_edges.add(edge) 258 | if ambiguous: 259 | for v in self._weight_curve( 260 | nodes[:], seen_edges.copy()): 261 | yield v 262 | nodes.extend(n for n in edge if n != node) 263 | yield len(seen_edges) 264 | 265 | def weight_sparse(self, edge): 266 | """ 267 | Weight diagonals based on feature sparseness. 268 | 269 | Sparse features are more likely to be seen as "foreground" 270 | rather than "background", and are therefore likely to be more 271 | important. 272 | """ 273 | offset = self.sparse_window_offset(edge) 274 | nodes = list(edge[:2]) 275 | seen_nodes = set(nodes) 276 | 277 | values = list(self._weight_sparse(offset, nodes, seen_nodes)) 278 | retvals = (min(values), max(values)) 279 | return retvals 280 | 281 | def _weight_sparse(self, offset, nodes, seen_nodes): 282 | while nodes: 283 | node = nodes.pop() 284 | for n in self.pixel_graph.neighbors(node): 285 | if n in seen_nodes: 286 | continue 287 | if 'ambiguous' in self.pixel_graph[node][n]: 288 | for v in self._weight_sparse( 289 | offset, nodes[:], seen_nodes.copy()): 290 | yield v 291 | if within_bounds(n, self.SPARSE_WINDOW_SIZE, offset): 292 | seen_nodes.add(n) 293 | nodes.append(n) 294 | 295 | yield -len(seen_nodes) 296 | 297 | def weight_island(self, edge): 298 | """ 299 | Weight diagonals connected to "islands". 300 | 301 | Single pixels connected to nothing except the edge being 302 | examined are likely to be more important. 303 | """ 304 | if (len(self.pixel_graph[edge[0]]) == 1 305 | or len(self.pixel_graph[edge[1]]) == 1): 306 | return (5, 5) 307 | return (0, 0) 308 | 309 | 310 | class PixelData(object): 311 | """ 312 | A representation of a pixel image that knows how to depixel it. 313 | 314 | :param data: A 2d array of pixel values. It is assumed to be rectangular. 315 | """ 316 | 317 | HEURISTICS = FullyConnectedHeuristics 318 | # HEURISTICS = IterativeFinalShapeHeuristics 319 | 320 | def __init__(self, pixels): 321 | self.pixels = pixels 322 | self.size_x = len(pixels[0]) 323 | self.size_y = len(pixels) 324 | self.size = (self.size_x, self.size_y) 325 | 326 | def depixel(self): 327 | """ 328 | Depixel the image. 329 | 330 | TODO: document. 331 | """ 332 | self.make_pixel_graph() 333 | self.remove_diagonals() 334 | self.make_grid_graph() 335 | self.deform_grid() 336 | self.make_shapes() 337 | self.isolate_outlines() 338 | self.add_shape_outlines() 339 | self.smooth_splines() 340 | 341 | def pixel(self, x, y): 342 | """ 343 | Convenience method for getting a pixel value. 344 | """ 345 | return self.pixels[y][x] 346 | 347 | def make_grid_graph(self): 348 | """ 349 | Build a graph representing the pixel grid. 350 | """ 351 | self.grid_graph = nx.grid_2d_graph(self.size_x + 1, self.size_y + 1) 352 | 353 | def make_pixel_graph(self): 354 | """ 355 | Build a graph representing the pixel data. 356 | """ 357 | self.pixel_graph = nx.Graph() 358 | 359 | for x, y in gen_coords(self.size): 360 | # While the nodes are created by adding edges, adding them 361 | # again is safe and lets us easily update metadata. 362 | corners = set([(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]) 363 | self.pixel_graph.add_node((x, y), 364 | value=self.pixel(x, y), corners=corners) 365 | # This gets called on each node, so we don't have to duplicate 366 | # edges. 367 | self._add_pixel_edge((x, y), (x + 1, y)) 368 | self._add_pixel_edge((x, y), (x, y + 1)) 369 | self._add_pixel_edge((x, y), (x + 1, y - 1)) 370 | self._add_pixel_edge((x, y), (x + 1, y + 1)) 371 | 372 | def _add_pixel_edge(self, pix0, pix1): 373 | """ 374 | Add an edge to the pixel graph, checking bounds and tagging diagonals. 375 | """ 376 | if within_bounds(pix1, self.size) and self.match(pix0, pix1): 377 | attrs = {'diagonal': pix0[0] != pix1[0] and pix0[1] != pix1[1]} 378 | self.pixel_graph.add_edge(pix0, pix1, **attrs) 379 | 380 | def match(self, pix0, pix1): 381 | """ 382 | Check if two pixels match. By default, this tests equality. 383 | """ 384 | return self.pixel(*pix0) == self.pixel(*pix1) 385 | 386 | def remove_diagonals(self): 387 | """ 388 | Remove all unnecessary diagonals and resolve checkerboard features. 389 | 390 | We examine all 2x2 pixel blocks and check for overlapping diagonals. 391 | The only cases in which diagonals will overlap are fully-connected 392 | blocks (in which both diagonals can be removed) and checkerboard blocks 393 | (in which we need to apply heuristics to determine which diagonal to 394 | remove). See the paper for details. 395 | """ 396 | ambiguous_diagonal_pairs = [] 397 | 398 | for nodes in self.walk_pixel_blocks(2): 399 | edges = [e for e in self.pixel_graph.edges(nodes, data=True) 400 | if e[0] in nodes and e[1] in nodes] 401 | 402 | diagonals = [e for e in edges if e[2]['diagonal']] 403 | if len(diagonals) == 2: 404 | if len(edges) == 6: 405 | # We have a fully-connected block, so remove all diagonals. 406 | for edge in diagonals: 407 | self.pixel_graph.remove_edge(edge[0], edge[1]) 408 | elif len(edges) == 2: 409 | # We have an ambiguous pair to resolve. 410 | ambiguous_diagonal_pairs.append(edges) 411 | else: 412 | # If we get here, we have an invalid graph, possibly due to 413 | # a faulty match function. 414 | assert False, "Unexpected diagonal layout" 415 | 416 | self.apply_diagonal_heuristics(ambiguous_diagonal_pairs) 417 | 418 | def apply_diagonal_heuristics(self, ambiguous_diagonal_pairs): 419 | self.HEURISTICS(self.pixel_graph).apply(ambiguous_diagonal_pairs) 420 | 421 | def walk_pixel_blocks(self, size): 422 | """ 423 | Walk the pixel graph in block of NxN pixels. 424 | 425 | This is useful for operating on a group of nodes at once. 426 | """ 427 | for x, y in gen_coords((self.size_x - size + 1, 428 | self.size_y - size + 1)): 429 | yield [(x + dx, y + dy) 430 | for dx in range(size) for dy in range(size)] 431 | 432 | def deform_grid(self): 433 | """ 434 | Deform the pixel grid based on the connections between similar pixels. 435 | """ 436 | for node in self.pixel_graph.nodes_iter(): 437 | self.deform_pixel(node) 438 | 439 | # Collapse all valence-2 nodes. 440 | removals = [] 441 | for node in self.grid_graph.nodes_iter(): 442 | if node in ((0, 0), (0, self.size[1]), 443 | (self.size[0], 0), self.size): 444 | # Skip corner nodes. 445 | continue 446 | neighbors = self.grid_graph.neighbors(node) 447 | if len(neighbors) == 2: 448 | self.grid_graph.add_edge(*neighbors) 449 | if len(neighbors) <= 2: 450 | removals.append(node) 451 | 452 | # We can't do this above, because it would modify the dict 453 | # we're iterating. 454 | for node in removals: 455 | self.grid_graph.remove_node(node) 456 | 457 | # Update pixel corner sets. 458 | for node, attrs in self.pixel_graph.nodes_iter(data=True): 459 | corners = attrs['corners'] 460 | for corner in corners.copy(): 461 | if corner not in self.grid_graph: 462 | corners.remove(corner) 463 | 464 | def deform_pixel(self, node): 465 | """ 466 | Deform an individual pixel. 467 | """ 468 | for neighbor in self.pixel_graph.neighbors(node): 469 | if node[0] == neighbor[0] or node[1] == neighbor[1]: 470 | # We only care about diagonals. 471 | continue 472 | px_x = max(neighbor[0], node[0]) 473 | px_y = max(neighbor[1], node[1]) 474 | pixnode = (px_x, px_y) 475 | offset_x = neighbor[0] - node[0] 476 | offset_y = neighbor[1] - node[1] 477 | # There's probably a better way to do this. 478 | adj_node = (neighbor[0], node[1]) 479 | if not self.match(node, adj_node): 480 | pn = (px_x, px_y - offset_y) 481 | mpn = (px_x, px_y - 0.5 * offset_y) 482 | npn = (px_x + 0.25 * offset_x, px_y - 0.25 * offset_y) 483 | remove_from_set(self.pixel_corners(adj_node), pixnode) 484 | self.pixel_corners(adj_node).add(npn) 485 | self.pixel_corners(node).add(npn) 486 | self._deform(pixnode, pn, mpn, npn) 487 | adj_node = (node[0], neighbor[1]) 488 | if not self.match(node, adj_node): 489 | pn = (px_x - offset_x, px_y) 490 | mpn = (px_x - 0.5 * offset_x, px_y) 491 | npn = (px_x - 0.25 * offset_x, px_y + 0.25 * offset_y) 492 | remove_from_set(self.pixel_corners(adj_node), pixnode) 493 | self.pixel_corners(adj_node).add(npn) 494 | self.pixel_corners(node).add(npn) 495 | self._deform(pixnode, pn, mpn, npn) 496 | 497 | def pixel_corners(self, pixel): 498 | return self.pixel_graph.node[pixel]['corners'] 499 | 500 | def _deform(self, pixnode, pn, mpn, npn): 501 | # Do the node and edge shuffling. 502 | if mpn in self.grid_graph: 503 | self.grid_graph.remove_edge(mpn, pixnode) 504 | else: 505 | self.grid_graph.remove_edge(pn, pixnode) 506 | self.grid_graph.add_edge(pn, mpn) 507 | self.grid_graph.add_edge(mpn, npn) 508 | self.grid_graph.add_edge(npn, pixnode) 509 | 510 | def make_shapes(self): 511 | self.shapes = set() 512 | 513 | for pcg in nx.connected_component_subgraphs(self.pixel_graph): 514 | pixels = set() 515 | value = None 516 | corners = set() 517 | for pixel, attrs in pcg.nodes_iter(data=True): 518 | pixels.add(pixel) 519 | corners.update(attrs['corners']) 520 | value = attrs['value'] 521 | self.shapes.add(Shape(pixels, value, corners)) 522 | 523 | def isolate_outlines(self): 524 | # Remove internal edges from a copy of our pixgrid graph. 525 | self.outlines_graph = nx.Graph(self.grid_graph) 526 | for pixel, attrs in self.pixel_graph.nodes_iter(data=True): 527 | corners = attrs['corners'] 528 | for neighbor in self.pixel_graph.neighbors(pixel): 529 | edge = corners & self.pixel_graph.node[neighbor]['corners'] 530 | if len(edge) != 2: 531 | print(edge) 532 | if self.outlines_graph.has_edge(*edge): 533 | self.outlines_graph.remove_edge(*edge) 534 | for node in nx.isolates(self.outlines_graph): 535 | self.outlines_graph.remove_node(node) 536 | 537 | def make_path(self, graph): 538 | path = Path(graph) 539 | key = path.key() 540 | if key not in self.paths: 541 | self.paths[key] = path 542 | path.make_spline() 543 | return self.paths[key] 544 | 545 | def add_shape_outlines(self): 546 | self.paths = {} 547 | 548 | for shape in self.shapes: 549 | sg = self.outlines_graph.subgraph(shape.corners) 550 | for graph in nx.connected_component_subgraphs(sg): 551 | path = self.make_path(graph) 552 | if (min(graph.nodes()) == min(sg.nodes())): 553 | shape.add_outline(path, True) 554 | else: 555 | shape.add_outline(path) 556 | 557 | def smooth_splines(self): 558 | print("Smoothing splines...") 559 | for i, path in enumerate(self.paths.values()): 560 | print(" * %s/%s (%s, %s)..." % ( 561 | i + 1, len(self.paths), len(path.shapes), len(path.path))) 562 | if len(path.shapes) == 1: 563 | path.smooth = path.spline.copy() 564 | continue 565 | path.smooth_spline() 566 | 567 | 568 | class Shape(object): 569 | def __init__(self, pixels, value, corners): 570 | self.pixels = pixels 571 | self.value = value 572 | self.corners = corners 573 | self._outside_path = None 574 | self._inside_paths = [] 575 | 576 | def _paths_attr(self, attr): 577 | paths = [list(reversed(getattr(self._outside_path, attr)))] 578 | paths.extend(getattr(path, attr) for path in self._inside_paths) 579 | 580 | @property 581 | def paths(self): 582 | paths = [list(reversed(self._outside_path.path))] 583 | paths.extend(path.path for path in self._inside_paths) 584 | return paths 585 | 586 | @property 587 | def splines(self): 588 | paths = [self._outside_path.spline.reversed()] 589 | paths.extend(path.spline for path in self._inside_paths) 590 | return paths 591 | 592 | @property 593 | def smooth_splines(self): 594 | paths = [self._outside_path.smooth.reversed()] 595 | paths.extend(path.smooth for path in self._inside_paths) 596 | return paths 597 | 598 | def add_outline(self, path, outside=False): 599 | if outside: 600 | self._outside_path = path 601 | else: 602 | self._inside_paths.append(path) 603 | path.shapes.add(self) 604 | 605 | 606 | class Path(object): 607 | def __init__(self, shape_graph): 608 | self.path = self._make_path(shape_graph) 609 | self.shapes = set() 610 | 611 | def key(self): 612 | return tuple(self.path) 613 | 614 | def _make_path(self, shape_graph): 615 | # Find initial nodes. 616 | nodes = set(shape_graph.nodes()) 617 | path = [min(nodes)] 618 | neighbors = sorted(shape_graph.neighbors(path[0]), 619 | key=lambda p: gradient(path[0], p)) 620 | path.append(neighbors[0]) 621 | nodes.difference_update(path) 622 | 623 | # Walk rest of nodes. 624 | while nodes: 625 | for neighbor in shape_graph.neighbors(path[-1]): 626 | if neighbor in nodes: 627 | nodes.remove(neighbor) 628 | path.append(neighbor) 629 | break 630 | return path 631 | 632 | def make_spline(self): 633 | self.spline = bspline.polyline_to_closed_bspline(self.path) 634 | 635 | def smooth_spline(self): 636 | self.smooth = bspline.smooth_spline(self.spline) 637 | -------------------------------------------------------------------------------- /depixel/io_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unified I/O module for reading and writing various formats. 3 | """ 4 | 5 | import os.path 6 | from itertools import product 7 | 8 | 9 | def gradient(p0, p1): 10 | dx = p1[0] - p0[0] 11 | dy = p1[1] - p0[1] 12 | if dx == 0: 13 | return dy * 99999999999999 14 | return 1.0 * dy / dx 15 | 16 | 17 | class PixelDataWriter(object): 18 | PIXEL_SCALE = 40 19 | GRID_COLOUR = (255, 127, 0) 20 | 21 | FILE_EXT = 'out' 22 | 23 | def __init__(self, pixel_data, name, scale=None, gridcolour=None): 24 | self.name = name 25 | self.pixel_data = pixel_data 26 | if scale: 27 | self.PIXEL_SCALE = scale 28 | if gridcolour: 29 | self.GRID_COLOUR = gridcolour 30 | 31 | def scale_pt(self, pt, offset=(0, 0)): 32 | return tuple(int((n + o) * self.PIXEL_SCALE) 33 | for n, o in zip(pt, offset)) 34 | 35 | def export_pixels(self, outdir): 36 | filename = self.mkfn(outdir, 'pixels') 37 | drawing = self.make_drawing('pixels', filename) 38 | for pt in product(range(self.pixel_data.size_x), 39 | range(self.pixel_data.size_y)): 40 | self.draw_pixel(drawing, pt, self.pixel_data.pixel(*pt)) 41 | self.save_drawing(drawing, filename) 42 | 43 | def export_grid(self, outdir, node_graph=True): 44 | filename = self.mkfn(outdir, 'grid') 45 | drawing = self.make_drawing('grid', filename) 46 | self.draw_pixgrid(drawing) 47 | if node_graph: 48 | self.draw_nodes(drawing) 49 | self.save_drawing(drawing, filename) 50 | 51 | def export_shapes(self, outdir, node_graph=True): 52 | filename = self.mkfn(outdir, 'shapes') 53 | drawing = self.make_drawing('shapes', filename) 54 | self.draw_shapes(drawing, 'splines') 55 | if node_graph: 56 | self.draw_nodes(drawing) 57 | self.save_drawing(drawing, filename) 58 | 59 | def export_smooth(self, outdir, node_graph=True): 60 | filename = self.mkfn(outdir, 'smooth') 61 | drawing = self.make_drawing('smooth', filename) 62 | self.draw_shapes(drawing, 'smooth_splines') 63 | if node_graph: 64 | self.draw_nodes(drawing) 65 | self.save_drawing(drawing, filename) 66 | 67 | def draw_pixgrid(self, drawing): 68 | for pixel, attrs in self.pixel_data.pixel_graph.nodes_iter(data=True): 69 | nodes = attrs['corners'].copy() 70 | path = [nodes.pop()] 71 | while nodes: 72 | for neighbor in self.pixel_data.grid_graph.neighbors(path[-1]): 73 | if neighbor in nodes: 74 | nodes.remove(neighbor) 75 | path.append(neighbor) 76 | break 77 | self.draw_polygon(drawing, [self.scale_pt(p) for p in path], 78 | self.GRID_COLOUR, attrs['value']) 79 | 80 | def draw_shapes(self, drawing, element='smooth_splines'): 81 | for shape in self.pixel_data.shapes: 82 | paths = getattr(shape, element) 83 | self.draw_spline_shape( 84 | drawing, paths, self.GRID_COLOUR, shape.value) 85 | 86 | def draw_nodes(self, drawing): 87 | for edge in self.pixel_data.pixel_graph.edges_iter(): 88 | self.draw_line(drawing, 89 | self.scale_pt(edge[0], (0.5, 0.5)), 90 | self.scale_pt(edge[1], (0.5, 0.5)), 91 | self.edge_colour(edge[0])) 92 | 93 | def edge_colour(self, node): 94 | return { 95 | 0: (0, 191, 0), 96 | 0.5: (191, 0, 0), 97 | 1: (0, 0, 255), 98 | (0, 0, 0): (0, 191, 0), 99 | (127, 127, 127): (191, 0, 0), 100 | (255, 255, 255): (0, 0, 255), 101 | }[self.pixel_data.pixel_graph.node[node]['value']] 102 | 103 | def mkfn(self, outdir, drawing_type): 104 | return os.path.join( 105 | outdir, "%s_%s.%s" % (drawing_type, self.name, self.FILE_EXT)) 106 | 107 | def make_drawing(self, drawing_type, filename): 108 | raise NotImplementedError("This Writer cannot make a drawing.") 109 | 110 | def save_drawing(self, filename): 111 | raise NotImplementedError("This Writer cannot save a drawing.") 112 | 113 | def draw_pixel(self, drawing, pt, colour): 114 | raise NotImplementedError("This Writer cannot draw a pixel.") 115 | 116 | def draw_rect(self, drawing, p0, size, colour, fill): 117 | raise NotImplementedError("This Writer cannot draw a rect.") 118 | 119 | def draw_line(self, drawing, p0, p1, colour): 120 | raise NotImplementedError("This Writer cannot draw a line.") 121 | 122 | def draw_path_shape(self, drawing, paths, colour, fill): 123 | raise NotImplementedError("This Writer cannot draw a path shape.") 124 | 125 | def draw_spline_shape(self, drawing, paths, colour, fill): 126 | raise NotImplementedError("This Writer cannot draw a spline shape.") 127 | 128 | 129 | def get_writer(data, basename, filetype): 130 | # Circular imports, but they're safe because they're in this function. 131 | if filetype == 'png': 132 | from depixel import io_png 133 | return io_png.PixelDataPngWriter(data, basename) 134 | 135 | if filetype == 'svg': 136 | from depixel import io_svg 137 | return io_svg.PixelDataSvgWriter(data, basename) 138 | 139 | raise NotImplementedError( 140 | "I don't recognise '%s' as a file type." % (filetype,)) 141 | 142 | 143 | def read_pixels(filename, filetype=None): 144 | if filetype is None: 145 | filetype = os.path.splitext(filename)[-1].lstrip('.') 146 | 147 | if filetype == 'png': 148 | from depixel.io_png import read_png 149 | return read_png(filename) 150 | -------------------------------------------------------------------------------- /depixel/io_png.py: -------------------------------------------------------------------------------- 1 | import png 2 | 3 | from depixel.io_data import PixelDataWriter 4 | 5 | 6 | class Bitmap(object): 7 | mode = 'RGB' 8 | bgcolour = (127, 127, 127) 9 | 10 | def __init__(self, size, bgcolour=None, mode=None): 11 | if bgcolour is not None: 12 | self.bgcolour = bgcolour 13 | if mode is not None: 14 | self.mode = mode 15 | self.size = size 16 | self.pixels = [] 17 | for _ in range(self.size[1]): 18 | self.pixels.append([bgcolour] * self.size[0]) 19 | 20 | def set_pixel(self, x, y, value): 21 | self.pixels[y][x] = value 22 | 23 | def pixel(self, x, y): 24 | return self.pixels[y][x] 25 | 26 | def set_data(self, data): 27 | assert len(data) == self.size[1] 28 | new_pixels = [] 29 | for row in data: 30 | assert len(row) == self.size[0] 31 | new_pixels.append(row[:]) 32 | self.pixels = new_pixels 33 | 34 | def set_block(self, x, y, data): 35 | assert 0 <= x <= (self.size[0] - len(data[0])) 36 | assert 0 <= y <= (self.size[1] - len(data)) 37 | for dy, row in enumerate(data): 38 | for dx, value in enumerate(row): 39 | self.set_pixel(x + dx, y + dy, value) 40 | 41 | def flat_pixels(self): 42 | flat_pixels = [] 43 | for row in self.pixels: 44 | frow = [] 45 | for value in row: 46 | frow.extend(value) 47 | flat_pixels.append(frow) 48 | return flat_pixels 49 | 50 | def write_png(self, filename): 51 | png.from_array(self.flat_pixels(), mode=self.mode).save(filename) 52 | 53 | def draw_line(self, p0, p1, colour): 54 | """Bresenham's line algorithm.""" 55 | 56 | x0, y0 = p0 57 | x1, y1 = p1 58 | dx = abs(x0 - x1) 59 | dy = abs(y0 - y1) 60 | sx = 1 if x0 < x1 else -1 61 | sy = 1 if y0 < y1 else -1 62 | err = dx - dy 63 | 64 | while (x0, y0) != (x1, y1): 65 | self.set_pixel(x0, y0, colour) 66 | e2 = 2 * err 67 | if e2 > -dy: 68 | err -= dy 69 | x0 += + sx 70 | if e2 < dx: 71 | err += dx 72 | y0 += sy 73 | self.set_pixel(x1, y1, colour) 74 | 75 | def fill(self, point, colour): 76 | old_colour = self.pixels[point[1]][point[0]] 77 | if old_colour == colour: 78 | return 79 | self.fill_scan(point, old_colour, colour) 80 | 81 | def fill_pix(self, point, old_colour, colour): 82 | """ 83 | Pixel flood-fill. Reliable, but slow. 84 | """ 85 | to_fill = [point] 86 | while to_fill: 87 | x, y = to_fill.pop() 88 | self.set_pixel(x, y, colour) 89 | for nx, ny in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]: 90 | if 0 <= nx < self.size[0] and 0 <= ny < self.size[1]: 91 | if self.pixels[ny][nx] == old_colour: 92 | to_fill.append((nx, ny)) 93 | 94 | def fill_scan(self, point, old_colour, colour): 95 | """ 96 | Scanline flood-fill. Fast, but I'm not entirely sure what it's doing. 97 | """ 98 | to_fill = [point] 99 | while to_fill: 100 | x, y = to_fill.pop() 101 | while y > 0 and self.pixel(x, y - 1) == old_colour: 102 | y -= 1 103 | lspan = False 104 | rspan = False 105 | while y < self.size[1] and self.pixel(x, y) == old_colour: 106 | self.set_pixel(x, y, colour) 107 | 108 | if not lspan and x > 0 and self.pixel(x - 1, y) == old_colour: 109 | to_fill.append((x - 1, y)) 110 | lspan = True 111 | elif lspan and x > 0 and self.pixel(x - 1, y) == old_colour: 112 | lspan = False 113 | 114 | if (not rspan and x < self.size[0] - 1 115 | and self.pixel(x + 1, y) == old_colour): 116 | to_fill.append((x + 1, y)) 117 | rspan = True 118 | elif (rspan and x < self.size[0] - 1 119 | and self.pixel(x + 1, y) == old_colour): 120 | rspan = False 121 | 122 | y += 1 123 | 124 | 125 | class PixelDataPngWriter(PixelDataWriter): 126 | FILE_EXT = 'png' 127 | 128 | def translate_pixel(self, pixel): 129 | if not isinstance(pixel, (list, tuple)): 130 | # Assume monochrome values normalised to [0, 1]. 131 | return (int(255 * pixel),) * 3 132 | return pixel 133 | 134 | def make_drawing(self, drawing_type, _filename): 135 | if drawing_type == 'pixels': 136 | return Bitmap(self.pixel_data.size) 137 | return Bitmap((self.pixel_data.size_x * self.PIXEL_SCALE + 1, 138 | self.pixel_data.size_y * self.PIXEL_SCALE + 1), 139 | bgcolour=(127, 127, 127)) 140 | 141 | def save_drawing(self, drawing, filename): 142 | drawing.write_png(filename) 143 | 144 | def draw_pixel(self, drawing, pt, colour): 145 | drawing.set_pixel(pt[0], pt[1], self.translate_pixel(colour)) 146 | 147 | def draw_line(self, drawing, pt0, pt1, colour): 148 | drawing.draw_line(pt0, pt1, self.translate_pixel(colour)) 149 | 150 | def draw_polygon(self, drawing, path, colour, fill): 151 | pt0 = path[-1] 152 | for pt1 in path: 153 | self.draw_line(drawing, pt0, pt1, colour) 154 | pt0 = pt1 155 | middle = (sum([p[0] for p in path]) / len(path), 156 | sum([p[1] for p in path]) / len(path)) 157 | drawing.fill(middle, fill) 158 | 159 | def draw_path_shape(self, drawing, paths, colour, fill): 160 | for path in paths: 161 | pt0 = path[-1] 162 | for pt1 in path: 163 | self.draw_line(drawing, pt0, pt1, colour) 164 | pt0 = pt1 165 | drawing.fill(self.find_point_within(paths, fill), fill) 166 | 167 | def find_point_within(self, paths, colour): 168 | for node, attrs in self.pixel_data.pixel_graph.nodes_iter(data=True): 169 | if colour == attrs['value']: 170 | pt = self.scale_pt(node, (0.5, 0.5)) 171 | if self.is_inside(pt, paths): 172 | return pt 173 | 174 | def is_inside(self, pt, paths): 175 | if not self._is_inside(pt, paths[0]): 176 | # Must be inside the "outside" path. 177 | return False 178 | for path in paths[1:]: 179 | if self._is_inside(pt, path): 180 | # Must be outside the "inside" paths. 181 | return False 182 | return True 183 | 184 | def _is_inside(self, pt, path): 185 | inside = False 186 | x, y = pt 187 | x0, y0 = path[-1] 188 | for x1, y1 in path: 189 | if (y0 <= y < y1 or y1 <= y < y0) and (x0 <= x or x1 <= x): 190 | # This crosses our ray. 191 | if (x1 + float(y - y1) / (y0 - y1) * (x0 - x1)) < x: 192 | inside = not inside 193 | x0, y0 = x1, y1 194 | return inside 195 | 196 | def draw_shapes(self, drawing, element=None): 197 | for shape in self.pixel_data.shapes: 198 | paths = [[self.scale_pt(p) for p in path] 199 | for path in shape['paths']] 200 | self.draw_path_shape( 201 | drawing, paths, self.GRID_COLOUR, shape['value']) 202 | 203 | 204 | def read_png(filename): 205 | _w, _h, pixels, _meta = png.Reader(filename=filename).asRGB8() 206 | data = [] 207 | for row in pixels: 208 | d_row = [] 209 | while row: 210 | d_row.append((row.pop(0), row.pop(0), row.pop(0))) 211 | data.append(d_row) 212 | return data 213 | -------------------------------------------------------------------------------- /depixel/io_svg.py: -------------------------------------------------------------------------------- 1 | from svgwrite import Drawing 2 | 3 | from depixel.io_data import PixelDataWriter 4 | 5 | 6 | def rgb(rgb): 7 | return "rgb(%s,%s,%s)" % rgb 8 | 9 | 10 | class PixelDataSvgWriter(PixelDataWriter): 11 | FILE_EXT = 'svg' 12 | PIXEL_BORDER = None 13 | 14 | def make_drawing(self, _drawing_type, filename): 15 | return Drawing(filename) 16 | 17 | def save_drawing(self, drawing, _drawing_type): 18 | drawing.save() 19 | 20 | def draw_pixel(self, drawing, pt, colour): 21 | pixel_border = self.PIXEL_BORDER 22 | if pixel_border is None: 23 | pixel_border = colour 24 | drawing.add(drawing.rect(self.scale_pt(pt), self.scale_pt((1, 1)), 25 | stroke=rgb(pixel_border), fill=rgb(colour))) 26 | 27 | def draw_line(self, drawing, pt0, pt1, colour): 28 | drawing.add(drawing.line(pt0, pt1, stroke=rgb(colour))) 29 | 30 | def draw_polygon(self, drawing, path, colour, fill): 31 | drawing.add(drawing.polygon(path, stroke=rgb(colour), fill=rgb(fill))) 32 | 33 | def draw_path_shape(self, drawing, paths, colour, fill): 34 | dpath = [] 35 | for path in paths: 36 | dpath.append('M') 37 | dpath.extend(path) 38 | dpath.append('Z') 39 | drawing.add(drawing.path(dpath, stroke=rgb(colour), fill=rgb(fill))) 40 | 41 | def draw_spline_shape(self, drawing, splines, colour, fill): 42 | if fill == (255, 255, 255): 43 | # Don't draw plain white shapes. 44 | return 45 | dpath = [] 46 | for spline in splines: 47 | bcurves = list(spline.quadratic_bezier_segments()) 48 | dpath.append('M') 49 | dpath.append(self.scale_pt(bcurves[0][0])) 50 | for bcurve in bcurves: 51 | dpath.append('Q') 52 | dpath.append(self.scale_pt(bcurve[1])) 53 | dpath.append(self.scale_pt(bcurve[2])) 54 | dpath.append('Z') 55 | drawing.add(drawing.path(dpath, stroke=rgb(colour), fill=rgb(fill))) 56 | 57 | def draw_shape(self, drawing, shape): 58 | self.draw_curve_shape(drawing, shape['splines'], 59 | self.GRID_COLOUR, shape['value']) 60 | -------------------------------------------------------------------------------- /depixel/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerith/depixel/d2012015043ab9a530919f2134f1a1d15d3960ab/depixel/scripts/__init__.py -------------------------------------------------------------------------------- /depixel/scripts/depixel_png.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from optparse import OptionParser 4 | import os.path 5 | 6 | from depixel import io_data 7 | from depixel.depixeler import PixelData 8 | 9 | 10 | def parse_options(): 11 | parser = OptionParser(usage="usage: %prog [options] file [file [...]]") 12 | parser.add_option('--write-grid', help="Write pixel grid file.", 13 | dest="write_grid", action="store_true", default=False) 14 | parser.add_option('--write-shapes', help="Write object shapes file.", 15 | dest="write_shapes", action="store_true", default=False) 16 | parser.add_option('--write-smooth', help="Write smooth shapes file.", 17 | dest="write_smooth", action="store_true", default=False) 18 | parser.add_option('--no-nodes', help="Suppress pixel node graph output.", 19 | dest="draw_nodes", action="store_false", default=True) 20 | parser.add_option('--write-pixels', help="Write pixel file.", 21 | dest="write_pixels", action="store_true", default=False) 22 | parser.add_option('--to-png', help="Write PNG output.", 23 | dest="to_png", action="store_true", default=False) 24 | parser.add_option('--to-svg', help="Write SVG output.", 25 | dest="to_svg", action="store_true", default=False) 26 | parser.add_option('--output-dir', metavar='DIR', default=".", 27 | help="Directory for output files. [%default]", 28 | dest="output_dir", action="store") 29 | 30 | options, args = parser.parse_args() 31 | if not args: 32 | parser.error("You must provide at least one input file.") 33 | 34 | return options, args 35 | 36 | 37 | def process_file(options, filename): 38 | print("Processing %s..." % (filename,)) 39 | data = PixelData(io_data.read_pixels(filename, 'png')) 40 | base_filename = os.path.splitext(os.path.split(filename)[-1])[0] 41 | outdir = options.output_dir 42 | 43 | filetypes = [] 44 | if options.to_png: 45 | filetypes.append('PNG') 46 | if options.to_svg: 47 | filetypes.append('SVG') 48 | 49 | if options.write_pixels: 50 | for ft in filetypes: 51 | print(" Writing pixels %s..." % (ft,)) 52 | writer = io_data.get_writer(data, base_filename, ft.lower()) 53 | writer.export_pixels(outdir) 54 | 55 | data.depixel() 56 | 57 | if options.write_grid: 58 | for ft in filetypes: 59 | print(" Writing grid %s..." % (ft,)) 60 | writer = io_data.get_writer(data, base_filename, ft.lower()) 61 | writer.export_grid(outdir, options.draw_nodes) 62 | 63 | if options.write_shapes: 64 | for ft in filetypes: 65 | print(" Writing shapes %s..." % (ft,)) 66 | writer = io_data.get_writer(data, base_filename, ft.lower()) 67 | writer.export_shapes(outdir, options.draw_nodes) 68 | 69 | if options.write_smooth: 70 | for ft in filetypes: 71 | print(" Writing smooth shapes %s..." % (ft,)) 72 | writer = io_data.get_writer(data, base_filename, ft.lower()) 73 | writer.export_smooth(outdir, options.draw_nodes) 74 | 75 | 76 | def main(): 77 | options, args = parse_options() 78 | for filename in args: 79 | process_file(options, filename) 80 | 81 | 82 | if __name__ == '__main__': 83 | main() 84 | -------------------------------------------------------------------------------- /depixel/scripts/export_test_image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from optparse import OptionParser 4 | 5 | from depixel import io_data 6 | from depixel.depixeler import PixelData 7 | from depixel.tests import test_depixeler 8 | 9 | 10 | def parse_options(): 11 | parser = OptionParser(usage="usage: %prog [options] name") 12 | parser.add_option('--output-dir', metavar='DIR', default=".", 13 | help="Directory for output files. [%default]", 14 | dest="output_dir", action="store") 15 | 16 | options, args = parser.parse_args() 17 | if len(args) != 1: 18 | parser.error("You must provide exactly one test image name.") 19 | 20 | return options, args 21 | 22 | 23 | def export_image(options, name): 24 | name = name.upper() 25 | print("Processing %s..." % (name,)) 26 | data = PixelData(test_depixeler.mkpixels(getattr(test_depixeler, name))) 27 | base_filename = name.lower() 28 | outdir = options.output_dir 29 | 30 | print(" Writing pixels PNG...") 31 | writer = io_data.get_writer(data, base_filename, 'png') 32 | writer.export_pixels(outdir) 33 | 34 | 35 | def main(): 36 | options, args = parse_options() 37 | export_image(options, args[0]) 38 | 39 | 40 | if __name__ == '__main__': 41 | main() 42 | -------------------------------------------------------------------------------- /depixel/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerith/depixel/d2012015043ab9a530919f2134f1a1d15d3960ab/depixel/tests/__init__.py -------------------------------------------------------------------------------- /depixel/tests/test_bspline.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from depixel.bspline import Point, BSpline 4 | 5 | 6 | def make_oct_spline(p=2, offset_x=0, offset_y=0, scale=50): 7 | base = [(2, 2), (4, 2), (5, 3), (5, 5), (4, 6), (2, 6), (1, 5), (1, 3)] 8 | points = [(x * scale + offset_x, y * scale + offset_y) for x, y in base] 9 | points = points + points[:p] 10 | m = len(points) + p 11 | knots = [float(i) / m for i in range(m + 1)] 12 | return BSpline(knots, points, p) 13 | 14 | 15 | class TestBSpline(TestCase): 16 | def test_spline_degree(self): 17 | knots = [0, 0.25, 0.5, 0.75, 1] 18 | points = [(0, 0), (1, 1)] 19 | self.assertEqual(2, BSpline(knots, points).degree) 20 | self.assertEqual(2, BSpline(knots, points, 2).degree) 21 | try: 22 | BSpline(knots, points, 3) 23 | self.fail("Expected ValueError.") 24 | except ValueError as e: 25 | self.assertEqual("Expected degree 2, got 3.", e.args[0]) 26 | 27 | def test_spline_domain(self): 28 | spline = make_oct_spline() 29 | self.assertEqual((0.5 / 3, 1 - 0.5 / 3), spline.domain) 30 | self.assertEqual((spline.knots[2], spline.knots[-3]), spline.domain) 31 | 32 | def test_spline_point_at_knot(self): 33 | spline = make_oct_spline() 34 | self.assertEqual(Point((150, 300)), spline(0.5).round()) 35 | 36 | def test_spline_derivative(self): 37 | spline = make_oct_spline() 38 | deriv = spline.derivative() 39 | self.assertEqual(deriv.degree, spline.degree - 1) 40 | self.assertEqual(deriv.knots, spline.knots[1:-1]) 41 | self.assertEqual(len(deriv.points), len(spline.points) - 1) 42 | 43 | def test_curvature(self): 44 | spline = make_oct_spline() 45 | self.assertEqual(0.005, round(spline.curvature(0.5), 5)) 46 | -------------------------------------------------------------------------------- /depixel/tests/test_depixeler.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import networkx as nx 4 | 5 | from depixel.depixeler import PixelData 6 | from depixel.depixeler import ( 7 | FullyConnectedHeuristics, IterativeFinalShapeHeuristics) 8 | 9 | 10 | BAR = """ 11 | XXXX 12 | X..X 13 | XXXX 14 | """ 15 | 16 | EAR = """ 17 | ...... 18 | ..XX.. 19 | .X..X. 20 | .X..X. 21 | ....X. 22 | ....X. 23 | ...... 24 | """ 25 | 26 | CIRCLE = """ 27 | ...... 28 | ..XX.. 29 | .X..X. 30 | .X..X. 31 | ..XX.. 32 | ...... 33 | """ 34 | 35 | PLUS = """ 36 | ..X.. 37 | ..X.. 38 | XXXXX 39 | ..X.. 40 | ..X.. 41 | """ 42 | 43 | ISLAND = """ 44 | .... 45 | .X.. 46 | ..XX 47 | """ 48 | 49 | CEE = """ 50 | ............... 51 | ......XXXX..XX. 52 | ....XXooooXXoX. 53 | ...XoooXXXoooX. 54 | ..XoooX...XooX. 55 | ..XooX.....XoX. 56 | .XoooX......XX. 57 | .XoooX......... 58 | .XoooX......... 59 | .XoooX......... 60 | .XoooX......... 61 | ..XooX......XX. 62 | ..XoooX....XoX. 63 | ...XoooXXXXoX.. 64 | ....XXoooooX... 65 | ......XXXXX.... 66 | ............... 67 | """ 68 | 69 | INVADER = """ 70 | .............. 71 | .....XXXX..... 72 | ..XXXXXXXXXX.. 73 | .XXXXXXXXXXXX. 74 | .XXX..XX..XXX. 75 | .XXXXXXXXXXXX. 76 | ....XX..XX.... 77 | ...XX.XX.XX... 78 | .XX........XX. 79 | .............. 80 | """ 81 | 82 | BIGINVADER = """ 83 | .................... 84 | .................... 85 | .................... 86 | .................... 87 | ........XXXX........ 88 | .....XXXXXXXXXX..... 89 | ....XXXXXXXXXXXX.... 90 | ....XXX..XX..XXX.... 91 | ....XXXXXXXXXXXX.... 92 | .......XX..XX....... 93 | ......XX.XX.XX...... 94 | ....XX........XX.... 95 | .................... 96 | .................... 97 | .................... 98 | .................... 99 | """ 100 | 101 | 102 | def mkpixels(txt_data): 103 | pixels = [] 104 | for line in txt_data.splitlines(): 105 | line = line.strip() 106 | if line: 107 | pixels.append([{'.': 1, 'o': 0.5, 'X': 0}[c] for c in line]) 108 | # pixels.append([{'.': 0, 'o': 0, 'X': 1}[c] for c in line]) 109 | return pixels 110 | 111 | 112 | def sort_edges(edges): 113 | return sorted(tuple(sorted(e[:2])) + e[2:] for e in edges) 114 | 115 | 116 | class TestUtils(TestCase): 117 | def test_mkpixels(self): 118 | ear_pixels = [ 119 | [1, 1, 1, 1, 1, 1], 120 | [1, 1, 0, 0, 1, 1], 121 | [1, 0, 1, 1, 0, 1], 122 | [1, 0, 1, 1, 0, 1], 123 | [1, 1, 1, 1, 0, 1], 124 | [1, 1, 1, 1, 0, 1], 125 | [1, 1, 1, 1, 1, 1], 126 | ] 127 | self.assertEqual(ear_pixels, mkpixels(EAR)) 128 | 129 | 130 | class TestFullyConnectedHeuristics(TestCase): 131 | def get_heuristics(self, txt_data): 132 | pd = PixelData(mkpixels(txt_data)) 133 | pd.make_pixel_graph() 134 | return FullyConnectedHeuristics(pd.pixel_graph) 135 | 136 | def test_weight_curve(self): 137 | hh = self.get_heuristics(EAR) 138 | self.assertEqual(1, hh.weight_curve(((0, 0), (1, 1)))) 139 | self.assertEqual(1, hh.weight_curve(((1, 1), (2, 2)))) 140 | self.assertEqual(7, hh.weight_curve(((1, 2), (2, 1)))) 141 | 142 | hh = self.get_heuristics(CIRCLE) 143 | self.assertEqual(1, hh.weight_curve(((0, 0), (1, 1)))) 144 | self.assertEqual(1, hh.weight_curve(((1, 1), (2, 2)))) 145 | self.assertEqual(8, hh.weight_curve(((1, 2), (2, 1)))) 146 | 147 | def test_weight_sparse(self): 148 | # EAR = """ 149 | # ..... . 150 | # ..XX. . 151 | # .X..X . 152 | # .X..X . 153 | # ....X . 154 | 155 | # ....X . 156 | # ..... . 157 | # """ 158 | hh = self.get_heuristics(EAR) 159 | self.assertEqual(-18, hh.weight_sparse(((0, 0), (1, 1)))) 160 | self.assertEqual(-28, hh.weight_sparse(((1, 1), (2, 2)))) 161 | self.assertEqual(-8, hh.weight_sparse(((1, 2), (2, 1)))) 162 | 163 | hh = self.get_heuristics(PLUS) 164 | self.assertEqual(-4, hh.weight_sparse(((0, 0), (1, 1)))) 165 | self.assertEqual(-9, hh.weight_sparse(((1, 2), (2, 1)))) 166 | 167 | def test_weight_island(self): 168 | hh = self.get_heuristics(ISLAND) 169 | self.assertEqual(5, hh.weight_island(((1, 1), (2, 2)))) 170 | self.assertEqual(0, hh.weight_island(((1, 2), (2, 1)))) 171 | 172 | 173 | class TestIterativeFinalShapeHeuristics(TestCase): 174 | def get_heuristics(self, txt_data): 175 | pd = PixelData(mkpixels(txt_data)) 176 | pd.make_pixel_graph() 177 | return IterativeFinalShapeHeuristics(pd.pixel_graph) 178 | 179 | def test_weight_curve(self): 180 | hh = self.get_heuristics(EAR) 181 | self.assertEqual((1, 1), hh.weight_curve(((0, 0), (1, 1)))) 182 | self.assertEqual((1, 1), hh.weight_curve(((1, 1), (2, 2)))) 183 | self.assertEqual((7, 7), hh.weight_curve(((1, 2), (2, 1)))) 184 | 185 | hh = self.get_heuristics(CIRCLE) 186 | self.assertEqual((1, 1), hh.weight_curve(((0, 0), (1, 1)))) 187 | self.assertEqual((1, 1), hh.weight_curve(((1, 1), (2, 2)))) 188 | self.assertEqual((8, 8), hh.weight_curve(((1, 2), (2, 1)))) 189 | 190 | def test_weight_sparse(self): 191 | hh = self.get_heuristics(EAR) 192 | self.assertEqual((-18, -18), hh.weight_sparse(((0, 0), (1, 1)))) 193 | self.assertEqual((-28, -28), hh.weight_sparse(((1, 1), (2, 2)))) 194 | self.assertEqual((-8, -8), hh.weight_sparse(((1, 2), (2, 1)))) 195 | 196 | hh = self.get_heuristics(PLUS) 197 | self.assertEqual((-4, -4), hh.weight_sparse(((0, 0), (1, 1)))) 198 | self.assertEqual((-9, -9), hh.weight_sparse(((1, 2), (2, 1)))) 199 | 200 | def test_weight_island(self): 201 | hh = self.get_heuristics(ISLAND) 202 | self.assertEqual((5, 5), hh.weight_island(((1, 1), (2, 2)))) 203 | self.assertEqual((0, 0), hh.weight_island(((1, 2), (2, 1)))) 204 | 205 | 206 | class TestPixelData(TestCase): 207 | def test_size(self): 208 | pd = PixelData([[1, 1], [1, 1], [1, 1]]) 209 | self.assertEqual((2, 3), pd.size) 210 | self.assertEqual((pd.size_x, pd.size_y), pd.size) 211 | 212 | pd = PixelData([[1, 1, 1], [1, 1, 1]]) 213 | self.assertEqual((3, 2), pd.size) 214 | self.assertEqual((pd.size_x, pd.size_y), pd.size) 215 | 216 | pd = PixelData(mkpixels(EAR)) 217 | self.assertEqual((6, 7), pd.size) 218 | self.assertEqual((pd.size_x, pd.size_y), pd.size) 219 | 220 | def test_pixel_graph(self): 221 | tg = nx.Graph() 222 | tg.add_nodes_from([ 223 | ((0, 0), {'value': 1, 224 | 'corners': set([(0, 0), (0, 1), (1, 0), (1, 1)])}), 225 | ((0, 1), {'value': 1, 226 | 'corners': set([(0, 1), (0, 2), (1, 1), (1, 2)])}), 227 | ((0, 2), {'value': 1, 228 | 'corners': set([(0, 2), (0, 3), (1, 2), (1, 3)])}), 229 | ((1, 0), {'value': 1, 230 | 'corners': set([(1, 0), (1, 1), (2, 0), (2, 1)])}), 231 | ((1, 1), {'value': 0, 232 | 'corners': set([(1, 1), (1, 2), (2, 1), (2, 2)])}), 233 | ((1, 2), {'value': 1, 234 | 'corners': set([(1, 2), (1, 3), (2, 2), (2, 3)])}), 235 | ((2, 0), {'value': 1, 236 | 'corners': set([(2, 0), (2, 1), (3, 0), (3, 1)])}), 237 | ((2, 1), {'value': 1, 238 | 'corners': set([(2, 1), (2, 2), (3, 1), (3, 2)])}), 239 | ((2, 2), {'value': 0, 240 | 'corners': set([(2, 2), (2, 3), (3, 2), (3, 3)])}), 241 | ((3, 0), {'value': 1, 242 | 'corners': set([(3, 0), (3, 1), (4, 0), (4, 1)])}), 243 | ((3, 1), {'value': 1, 244 | 'corners': set([(3, 1), (3, 2), (4, 1), (4, 2)])}), 245 | ((3, 2), {'value': 0, 246 | 'corners': set([(3, 2), (3, 3), (4, 2), (4, 3)])}), 247 | ]) 248 | tg.add_edges_from([ 249 | ((0, 0), (1, 0), {'diagonal': False}), 250 | ((0, 1), (0, 0), {'diagonal': False}), 251 | ((0, 1), (0, 2), {'diagonal': False}), 252 | ((0, 1), (1, 0), {'diagonal': True}), 253 | ((0, 1), (1, 2), {'diagonal': True}), 254 | ((1, 1), (2, 2), {'diagonal': True}), 255 | ((1, 2), (0, 2), {'diagonal': False}), 256 | ((1, 2), (2, 1), {'diagonal': True}), 257 | ((2, 0), (1, 0), {'diagonal': False}), 258 | ((2, 1), (1, 0), {'diagonal': True}), 259 | ((2, 1), (2, 0), {'diagonal': False}), 260 | ((3, 0), (2, 0), {'diagonal': False}), 261 | ((3, 0), (2, 1), {'diagonal': True}), 262 | ((3, 0), (3, 1), {'diagonal': False}), 263 | ((3, 1), (2, 0), {'diagonal': True}), 264 | ((3, 1), (2, 1), {'diagonal': False}), 265 | ((3, 2), (2, 2), {'diagonal': False}), 266 | ]) 267 | 268 | pd = PixelData(mkpixels(ISLAND)) 269 | pd.make_pixel_graph() 270 | self.assertEqual(sorted(tg.nodes(data=True)), 271 | sorted(pd.pixel_graph.nodes(data=True))) 272 | self.assertEqual(sort_edges(tg.edges(data=True)), 273 | sort_edges(pd.pixel_graph.edges(data=True))) 274 | 275 | def test_remove_diagonals(self): 276 | tg = nx.Graph() 277 | tg.add_nodes_from([ 278 | ((0, 0), {'value': 1, 279 | 'corners': set([(0, 0), (0, 1), (1, 0), (1, 1)])}), 280 | ((0, 1), {'value': 1, 281 | 'corners': set([(0, 1), (0, 2), (1, 1), (1, 2)])}), 282 | ((0, 2), {'value': 1, 283 | 'corners': set([(0, 2), (0, 3), (1, 2), (1, 3)])}), 284 | ((1, 0), {'value': 1, 285 | 'corners': set([(1, 0), (1, 1), (2, 0), (2, 1)])}), 286 | ((1, 1), {'value': 0, 287 | 'corners': set([(1, 1), (1, 2), (2, 1), (2, 2)])}), 288 | ((1, 2), {'value': 1, 289 | 'corners': set([(1, 2), (1, 3), (2, 2), (2, 3)])}), 290 | ((2, 0), {'value': 1, 291 | 'corners': set([(2, 0), (2, 1), (3, 0), (3, 1)])}), 292 | ((2, 1), {'value': 1, 293 | 'corners': set([(2, 1), (2, 2), (3, 1), (3, 2)])}), 294 | ((2, 2), {'value': 0, 295 | 'corners': set([(2, 2), (2, 3), (3, 2), (3, 3)])}), 296 | ((3, 0), {'value': 1, 297 | 'corners': set([(3, 0), (3, 1), (4, 0), (4, 1)])}), 298 | ((3, 1), {'value': 1, 299 | 'corners': set([(3, 1), (3, 2), (4, 1), (4, 2)])}), 300 | ((3, 2), {'value': 0, 301 | 'corners': set([(3, 2), (3, 3), (4, 2), (4, 3)])}), 302 | ]) 303 | tg.add_edges_from([ 304 | ((0, 0), (1, 0), {'diagonal': False}), 305 | ((0, 1), (0, 0), {'diagonal': False}), 306 | ((0, 1), (0, 2), {'diagonal': False}), 307 | ((0, 1), (1, 0), {'diagonal': True}), 308 | ((0, 1), (1, 2), {'diagonal': True}), 309 | ((1, 1), (2, 2), {'diagonal': True}), 310 | ((1, 2), (0, 2), {'diagonal': False}), 311 | # ((1, 2), (2, 1), {'diagonal': True}), 312 | ((2, 0), (1, 0), {'diagonal': False}), 313 | ((2, 1), (1, 0), {'diagonal': True}), 314 | ((2, 1), (2, 0), {'diagonal': False}), 315 | ((3, 0), (2, 0), {'diagonal': False}), 316 | # ((3, 0), (2, 1), {'diagonal': True}), 317 | ((3, 0), (3, 1), {'diagonal': False}), 318 | # ((3, 1), (2, 0), {'diagonal': True}), 319 | ((3, 1), (2, 1), {'diagonal': False}), 320 | ((3, 2), (2, 2), {'diagonal': False}), 321 | ]) 322 | 323 | pd = PixelData(mkpixels(ISLAND)) 324 | pd.make_pixel_graph() 325 | pd.remove_diagonals() 326 | self.assertEqual(sorted(tg.nodes(data=True)), 327 | sorted(pd.pixel_graph.nodes(data=True))) 328 | self.assertEqual(sort_edges(tg.edges(data=True)), 329 | sort_edges(pd.pixel_graph.edges(data=True))) 330 | 331 | def test_deform_grid(self): 332 | tg = nx.Graph() 333 | tg.add_nodes_from([ 334 | (0, 0), (0, 1), (0, 2), (0, 3), (1, 0), (1, 1), (1, 2), (1, 3), 335 | (1.25, 1.25), (1.25, 1.75), (1.75, 1.25), (1.75, 2.25), (2, 0), 336 | (2, 1), (2, 3), (2.25, 1.75), (3, 0), (3, 1), (3, 2), (3, 3), 337 | (4, 0), (4, 1), (4, 2), (4, 3), 338 | ]) 339 | tg.add_edges_from([ 340 | ((0, 0), (0, 1)), ((0, 1), (0, 2)), ((0, 3), (0, 2)), 341 | ((1, 0), (0, 0)), ((1, 0), (1, 1)), ((1, 0), (2, 0)), 342 | ((1, 1), (0, 1)), ((1, 2), (0, 2)), ((1, 3), (0, 3)), 343 | ((1, 3), (1, 2)), ((1, 3), (2, 3)), ((1.25, 1.25), (1, 1)), 344 | ((1.25, 1.25), (1.75, 1.25)), ((1.25, 1.75), (1, 2)), 345 | ((1.25, 1.75), (1.25, 1.25)), ((1.25, 1.75), (1.75, 2.25)), 346 | ((2, 1), (1.75, 1.25)), ((2, 1), (2, 0)), ((2, 1), (3, 1)), 347 | ((2, 3), (1.75, 2.25)), ((2.25, 1.75), (1.75, 1.25)), 348 | ((2.25, 1.75), (1.75, 2.25)), ((2.25, 1.75), (3, 2)), 349 | ((3, 0), (2, 0)), ((3, 0), (3, 1)), ((3, 0), (4, 0)), 350 | ((3, 2), (3, 1)), ((3, 2), (4, 2)), ((3, 3), (2, 3)), 351 | ((3, 3), (3, 2)), ((3, 3), (4, 3)), ((4, 0), (4, 1)), 352 | ((4, 1), (3, 1)), ((4, 1), (4, 2)), ((4, 2), (4, 3)), 353 | ]) 354 | 355 | pd = PixelData(mkpixels(ISLAND)) 356 | pd.depixel() 357 | 358 | self.assertEqual(sorted(tg.nodes()), sorted(pd.grid_graph.nodes())) 359 | self.assertEqual(sort_edges(tg.edges()), 360 | sort_edges(pd.grid_graph.edges())) 361 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | version = '0.1' 4 | 5 | setup(name='depixel', 6 | version=version, 7 | description='Depixeling tool, based on "Depixelizing Pixel Art"', 8 | long_description=open('README', 'rb').read().decode('utf-8'), 9 | # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 10 | classifiers=["Topic :: Multimedia :: Graphics"], 11 | author='Jeremy Thurgood', 12 | author_email='firxen@gmail.com', 13 | url='https://github.com/jerith/depixel', 14 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 15 | include_package_data=True, 16 | zip_safe=False, 17 | install_requires=[ 18 | 'setuptools', 19 | 'networkx', 20 | 'pypng', 21 | 'svgwrite', 22 | ], 23 | entry_points=""" 24 | [console_scripts] 25 | depixel_png = depixel.scripts.depixel_png:main 26 | """, 27 | ) 28 | --------------------------------------------------------------------------------