Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Epic commit, mostly about smoothing splines.

  • Loading branch information...
commit a0765bf7e09f4478bba6470d5f4c266244277311 1 parent 7674f1e
Jeremy Thurgood authored
14 README
View
@@ -11,10 +11,10 @@ tool to generate outline fonts from low-resolution bitmap fonts, but that isn't
ready yet.
So far I have the basic pixel grid deformation, shape outline extraction and
-very simple curve smoothing (polygon -> closed quadratic B-spline -> Bezier
-spline) implemented and I can write representations of the intermediate steps
-to PNG and SVG. (The curve smoothing currently only works for closed shapes,
-because I've been testing with simple monochrome images.)
+somewhat wonky curve smoothing implemented and I can write representations of
+the intermediate steps to PNG and SVG. (The curve smoothing is pretty
+experimental, and probably full of exciting bugs. It also operates on each
+shape individually, so there's weirdness along the edges.)
There is a handy script to depixel PNGs in the `depixel/scripts` directory, and
there are unit tests covering some of the code.
@@ -23,9 +23,9 @@ I like to keep dependencies small and light, but there are some useful bits
I've pulled in (or will pull in) to make life easier:
* I use `networkx` to do the graph stuff, because implementing it myself was
- getting messy. At some point I may write a streamlined graph implementation
- to include here and drop the dependency, but that's fairly low down my
- priority list at present.
+ getting messy. Switching to a more special-purpose graph library might give
+ some performance benefits, but that's fairly low down my priority list at
+ present.
* I use `pypng` to do the PNG reading and writing, but that's isolated in the
things that need it and the actual depixeling code works fine without it.
193 depixel/bspline.py
View
@@ -12,11 +12,13 @@
http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/B-spline/de-Boor.html
Errors are likely due to my lack of understanding rather than any deficiency in
-the source material. I haven't put in the time and effort to completely
-understand the underlying theory, so I may have done something silly. However,
-my tests seem to do the right thing.
+the source material. I don't completely understand the underlying theory, so I
+may have done something silly. However, my tests seem to do the right thing.
"""
+import random
+from math import sqrt
+
class BSpline(object):
"""
@@ -29,8 +31,8 @@ class BSpline(object):
* m = n + p + 1
"""
def __init__(self, knots, points, degree=None):
- self.knots = knots
- self.points = points
+ self.knots = tuple(knots)
+ self._points = list(points)
expected_degree = len(knots) - len(points) - 1
if degree is None:
degree = expected_degree
@@ -38,10 +40,35 @@ def __init__(self, knots, points, degree=None):
raise ValueError("Expected degree %s, got %s." % (
expected_degree, degree))
self.degree = degree
+ self._reset_cache()
+
+ def _reset_cache(self):
+ self._cache = {}
+
+ def move_point(self, i, value):
+ self._points[i] = value
+ self._reset_cache()
+
+ def __str__(self):
+ return "<%s degree=%s, points=%s, knots=%s>" % (
+ type(self).__name__,
+ self.degree, len(self.points), len(self.knots))
+
+ def copy(self):
+ return type(self)(self.knots, self.points, self.degree)
@property
def domain(self):
- return (self.knots[self.degree], self.knots[-1 - self.degree])
+ return (self.knots[self.degree],
+ self.knots[len(self.knots) - self.degree - 1])
+
+ @property
+ def points(self):
+ return tuple(self._points)
+
+ @property
+ def useful_points(self):
+ return self.points
def __call__(self, u):
"""
@@ -53,6 +80,10 @@ def __call__(self, u):
break
if s == 0:
k -= 1
+ if self.degree == 0:
+ if k == len(self.points):
+ k -= 1
+ return self.points[k]
ps = [dict(zip(range(k - self.degree, k - s + 1),
self.points[k - self.degree:k - s + 1]))]
@@ -80,6 +111,101 @@ def quadratic_bezier_segments(self):
yield (ocp0, cp, ocp1)
ocp0 = ocp1
+ def derivative(self):
+ """
+ Take the derivative.
+ """
+ cached = self._cache.get('derivative')
+ if cached:
+ return cached
+
+ new_points = []
+ p = self.degree
+ for i in range(0, len(self.points) - 1):
+ coeff = p / (self.knots[i + 1 + p] - self.knots[i + 1])
+ new_points.append((
+ coeff * (self.points[i + 1][0] - self.points[i][0]),
+ coeff * (self.points[i + 1][1] - self.points[i][1])))
+
+ cached = BSpline(self.knots[1:-1], new_points, p - 1)
+ self._cache['derivative'] = cached
+ return cached
+
+ def _clamp_domain(self, value):
+ return max(self.domain[0], min(self.domain[1], value))
+
+ def _get_span(self, index):
+ return (self._clamp_domain(self.knots[index]),
+ self._clamp_domain(self.knots[index + 1]))
+
+ def _get_point_spans(self, index):
+ return [self._get_span(index + i) for i in range(self.degree)]
+
+ def integrate_over_span(self, func, span, intervals):
+ if span[0] == span[1]:
+ return 0
+
+ interval = (span[1] - span[0]) / intervals
+ result = (func(span[0]) + func(span[1])) / 2
+ for i in xrange(1, intervals):
+ result += func(span[0] + i * interval)
+ result *= interval
+
+ return result
+
+ def integrate_for(self, index, func, intervals):
+ spans_ = self._get_point_spans(index)
+ spans = [span for span in spans_ if span[0] != span[1]]
+ return sum(self.integrate_over_span(func, span, intervals)
+ for span in spans)
+
+ def curvature(self, u):
+ drv1 = self.derivative()
+ drv2 = drv1.derivative()
+ d1, d2 = drv1(u), drv2(u)
+ num = abs(d1[0] * d2[1] - d2[0] * d1[1])
+ den = sqrt((d1[0] ** 2 + d1[1] ** 2) ** 3)
+ if den == 0:
+ return 0
+ return num / den
+
+ def curvature_energy(self, index, intervals_per_span):
+ return self.integrate_for(index, self.curvature, intervals_per_span)
+
+
+class ClosedBSpline(BSpline):
+ def __init__(self, knots, points, degree=None):
+ super(ClosedBSpline, self).__init__(knots, points, degree)
+ self._unwrapped_len = len(self._points) - self.degree
+ self._check_wrapped()
+
+ def _check_wrapped(self):
+ if self._points[:self.degree] != self._points[-self.degree:]:
+ raise ValueError(
+ "Points not wrapped at degree %s." % (self.degree,))
+
+ def move_point(self, index, value):
+ if not 0 <= index < len(self._points):
+ raise IndexError(index)
+ index = index % self._unwrapped_len
+ super(ClosedBSpline, self).move_point(index, value)
+ if index < self.degree:
+ super(ClosedBSpline, self).move_point(
+ index + self._unwrapped_len, value)
+
+ @property
+ def useful_points(self):
+ return self.points[:-self.degree]
+
+ def _get_span(self, index):
+ span = lambda i: (self.knots[i], self.knots[i + 1])
+ d0, d1 = span(index)
+ if d0 < self.domain[0]:
+ d0, d1 = span(index + len(self.points) - self.degree)
+ elif d1 > self.domain[1]:
+ d0, d1 = span(index + self.degree - len(self.points))
+ return self._clamp_domain(d0), self._clamp_domain(d1)
+
def polyline_to_closed_bspline(path, degree=2):
"""
@@ -90,4 +216,57 @@ def polyline_to_closed_bspline(path, degree=2):
m = len(points) + degree
knots = [float(i) / m for i in xrange(m + 1)]
- return BSpline(knots, points, degree)
+ return ClosedBSpline(knots, points, degree)
+
+
+class SplineSmoother(object):
+ INTERVALS_PER_SPAN = 10
+ POINT_GUESSES = 10
+ GUESS_OFFSET = 0.02
+ ITERATIONS = 10
+
+ # INTERVALS_PER_SPAN = 5
+ # POINT_GUESSES = 1
+ # ITERATIONS = 1
+
+ def __init__(self, spline):
+ self.orig = spline
+ self.spline = spline.copy()
+
+ def _e_curvature(self, index):
+ return self.spline.curvature_energy(index, self.INTERVALS_PER_SPAN)
+
+ def _e_positional(self, index):
+ orig = self.orig.points[index]
+ point = self.spline.points[index]
+ e_positional = sum((p[0] - p[1]) ** 2 for p in zip(point, orig)) ** 2
+ return e_positional
+
+ def point_energy(self, index):
+ e_curvature = self._e_curvature(index)
+ e_positional = self._e_positional(index)
+ return e_positional + e_curvature
+
+ def _rand(self):
+ return (random.random() - 0.5) * self.GUESS_OFFSET
+
+ def smooth_point(self, index, start):
+ energies = [(self.point_energy(index), start)]
+ for _ in range(self.POINT_GUESSES):
+ point = tuple(i + self._rand() for i in start)
+ self.spline.move_point(index, point)
+ energies.append((self.point_energy(index), point))
+ self.spline.move_point(index, min(energies)[1])
+
+ def smooth(self):
+ print "."
+ for _it in range(self.ITERATIONS):
+ # print "IT:", _it
+ for i, point in enumerate(self.spline.useful_points):
+ self.smooth_point(i, point)
+
+
+def smooth_spline(spline):
+ smoother = SplineSmoother(spline)
+ smoother.smooth()
+ return smoother.spline
9 depixel/depixeler.py
View
@@ -335,6 +335,7 @@ def depixel(self):
self.deform_grid()
self.make_shape_outlines()
self.make_splines()
+ self.smooth_splines()
def pixel(self, x, y):
"""
@@ -573,3 +574,11 @@ def make_path(self, shape_graph, outside=False):
path.append(neighbor)
break
return path
+
+ def smooth_splines(self):
+ for shape in self.shapes:
+ shape['smooth_splines'] = [
+ self.smooth_spline(s.copy()) for s in shape['splines']]
+
+ def smooth_spline(self, spline):
+ return bspline.smooth_spline(spline)
45 depixel/io_data.py
View
@@ -51,7 +51,15 @@ def export_grid(self, outdir, node_graph=True):
def export_shapes(self, outdir, node_graph=True):
filename = self.mkfn(outdir, 'shapes')
drawing = self.make_drawing('shapes', filename)
- self.draw_shapes(drawing)
+ self.draw_shapes(drawing, 'splines')
+ if node_graph:
+ self.draw_nodes(drawing)
+ self.save_drawing(drawing, filename)
+
+ def export_smooth(self, outdir, node_graph=True):
+ filename = self.mkfn(outdir, 'smooth')
+ drawing = self.make_drawing('smooth', filename)
+ self.draw_shapes(drawing, 'smooth_splines')
if node_graph:
self.draw_nodes(drawing)
self.save_drawing(drawing, filename)
@@ -69,36 +77,10 @@ def draw_pixgrid(self, drawing):
self.draw_polygon(drawing, [self.scale_pt(p) for p in path],
self.GRID_COLOUR, attrs['value'])
- def draw_shapes(self, drawing):
+ def draw_shapes(self, drawing, element='smooth_splines'):
for shape in self.pixel_data.shapes:
- self.draw_shape(drawing, shape)
-
- def draw_shape(self, drawing, shape):
- paths = [self.mkpath(shape['outside'])]
- for graph in shape['inside']:
- paths.append(self.mkpath(graph, True))
- self.draw_path_shape(drawing, paths, self.GRID_COLOUR, shape['value'])
-
- def mkpath(self, shape_graph, outside=False):
- # Find initial nodes.
- nodes = set(shape_graph.nodes())
- path = [min(nodes)]
- neighbors = sorted(shape_graph.neighbors(path[0]),
- key=lambda p: gradient(path[0], p))
- if outside:
- path.append(neighbors[-1])
- else:
- path.append(neighbors[0])
- nodes.difference_update(path)
-
- # Walk rest of nodes.
- while nodes:
- for neighbor in shape_graph.neighbors(path[-1]):
- if neighbor in nodes:
- nodes.remove(neighbor)
- path.append(neighbor)
- break
- return [self.scale_pt(p) for p in path]
+ self.draw_spline_shape(
+ drawing, shape[element], self.GRID_COLOUR, shape['value'])
def draw_nodes(self, drawing):
for edge in self.pixel_data.pixel_graph.edges_iter():
@@ -139,6 +121,9 @@ def draw_line(self, drawing, p0, p1, colour):
def draw_path_shape(self, drawing, paths, colour, fill):
raise NotImplementedError("This Writer cannot draw a path shape.")
+ def draw_spline_shape(self, drawing, paths, colour, fill):
+ raise NotImplementedError("This Writer cannot draw a spline shape.")
+
def get_writer(data, basename, filetype):
# Circular imports, but they're safe because they're in this function.
7 depixel/io_png.py
View
@@ -193,6 +193,13 @@ def _is_inside(self, pt, path):
x0, y0 = x1, y1
return inside
+ def draw_shapes(self, drawing, element=None):
+ for shape in self.pixel_data.shapes:
+ paths = [[self.scale_pt(p) for p in path]
+ for path in shape['paths']]
+ self.draw_path_shape(
+ drawing, paths, self.GRID_COLOUR, shape['value'])
+
def read_png(filename):
_w, _h, pixels, _meta = png.Reader(filename=filename).asRGB8()
7 depixel/io_svg.py
View
@@ -38,7 +38,10 @@ def draw_path_shape(self, drawing, paths, colour, fill):
dpath.append('Z')
drawing.add(drawing.path(dpath, stroke=rgb(colour), fill=rgb(fill)))
- def draw_curve_shape(self, drawing, splines, colour, fill):
+ def draw_spline_shape(self, drawing, splines, colour, fill):
+ if fill == (255, 255, 255):
+ # Don't draw plain white shapes.
+ return
dpath = []
for spline in splines:
bcurves = list(spline.quadratic_bezier_segments())
@@ -48,7 +51,7 @@ def draw_curve_shape(self, drawing, splines, colour, fill):
dpath.append('Q')
dpath.append(self.scale_pt(bcurve[1]))
dpath.append(self.scale_pt(bcurve[2]))
- # dpath.append('Z')
+ dpath.append('Z')
drawing.add(drawing.path(dpath, stroke=rgb(colour), fill=rgb(fill)))
def draw_shape(self, drawing, shape):
8 depixel/scripts/depixel_png.py
View
@@ -13,6 +13,8 @@ def parse_options():
dest="write_grid", action="store_true", default=False)
parser.add_option('--write-shapes', help="Write object shapes file.",
dest="write_shapes", action="store_true", default=False)
+ parser.add_option('--write-smooth', help="Write smooth shapes file.",
+ dest="write_smooth", action="store_true", default=False)
parser.add_option('--no-nodes', help="Suppress pixel node graph output.",
dest="draw_nodes", action="store_false", default=True)
parser.add_option('--write-pixels', help="Write pixel file.",
@@ -64,6 +66,12 @@ def process_file(options, filename):
writer = io_data.get_writer(data, base_filename, ft.lower())
writer.export_shapes(outdir, options.draw_nodes)
+ if options.write_smooth:
+ for ft in filetypes:
+ print " Writing smooth shapes %s..." % (ft,)
+ writer = io_data.get_writer(data, base_filename, ft.lower())
+ writer.export_smooth(outdir, options.draw_nodes)
+
def main():
options, args = parse_options()
72 depixel/tests/test_depixeler.py
View
@@ -7,6 +7,12 @@
FullyConnectedHeuristics, IterativeFinalShapeHeuristics)
+BAR = """
+XXXX
+X..X
+XXXX
+"""
+
EAR = """
......
..XX..
@@ -98,8 +104,8 @@ def mkpixels(txt_data):
for line in txt_data.splitlines():
line = line.strip()
if line:
- # pixels.append([{'.': 0, 'o': 0.5, 'X': 1}[c] for c in line])
- pixels.append([{'.': 0, 'o': 0, 'X': 1}[c] for c in line])
+ pixels.append([{'.': 1, 'o': 0.5, 'X': 0}[c] for c in line])
+ # pixels.append([{'.': 0, 'o': 0, 'X': 1}[c] for c in line])
return pixels
@@ -110,13 +116,13 @@ def sort_edges(edges):
class TestUtils(TestCase):
def test_mkpixels(self):
ear_pixels = [
- [0, 0, 0, 0, 0, 0],
- [0, 0, 1, 1, 0, 0],
- [0, 1, 0, 0, 1, 0],
- [0, 1, 0, 0, 1, 0],
- [0, 0, 0, 0, 1, 0],
- [0, 0, 0, 0, 1, 0],
- [0, 0, 0, 0, 0, 0],
+ [1, 1, 1, 1, 1, 1],
+ [1, 1, 0, 0, 1, 1],
+ [1, 0, 1, 1, 0, 1],
+ [1, 0, 1, 1, 0, 1],
+ [1, 1, 1, 1, 0, 1],
+ [1, 1, 1, 1, 0, 1],
+ [1, 1, 1, 1, 1, 1],
]
self.assertEqual(ear_pixels, mkpixels(EAR))
@@ -214,29 +220,29 @@ def test_size(self):
def test_pixel_graph(self):
tg = nx.Graph()
tg.add_nodes_from([
- ((0, 0), {'value': 0,
+ ((0, 0), {'value': 1,
'corners': set([(0, 0), (0, 1), (1, 0), (1, 1)])}),
- ((0, 1), {'value': 0,
+ ((0, 1), {'value': 1,
'corners': set([(0, 1), (0, 2), (1, 1), (1, 2)])}),
- ((0, 2), {'value': 0,
+ ((0, 2), {'value': 1,
'corners': set([(0, 2), (0, 3), (1, 2), (1, 3)])}),
- ((1, 0), {'value': 0,
+ ((1, 0), {'value': 1,
'corners': set([(1, 0), (1, 1), (2, 0), (2, 1)])}),
- ((1, 1), {'value': 1,
+ ((1, 1), {'value': 0,
'corners': set([(1, 1), (1, 2), (2, 1), (2, 2)])}),
- ((1, 2), {'value': 0,
+ ((1, 2), {'value': 1,
'corners': set([(1, 2), (1, 3), (2, 2), (2, 3)])}),
- ((2, 0), {'value': 0,
+ ((2, 0), {'value': 1,
'corners': set([(2, 0), (2, 1), (3, 0), (3, 1)])}),
- ((2, 1), {'value': 0,
+ ((2, 1), {'value': 1,
'corners': set([(2, 1), (2, 2), (3, 1), (3, 2)])}),
- ((2, 2), {'value': 1,
+ ((2, 2), {'value': 0,
'corners': set([(2, 2), (2, 3), (3, 2), (3, 3)])}),
- ((3, 0), {'value': 0,
+ ((3, 0), {'value': 1,
'corners': set([(3, 0), (3, 1), (4, 0), (4, 1)])}),
- ((3, 1), {'value': 0,
+ ((3, 1), {'value': 1,
'corners': set([(3, 1), (3, 2), (4, 1), (4, 2)])}),
- ((3, 2), {'value': 1,
+ ((3, 2), {'value': 0,
'corners': set([(3, 2), (3, 3), (4, 2), (4, 3)])}),
])
tg.add_edges_from([
@@ -269,29 +275,29 @@ def test_pixel_graph(self):
def test_remove_diagonals(self):
tg = nx.Graph()
tg.add_nodes_from([
- ((0, 0), {'value': 0,
+ ((0, 0), {'value': 1,
'corners': set([(0, 0), (0, 1), (1, 0), (1, 1)])}),
- ((0, 1), {'value': 0,
+ ((0, 1), {'value': 1,
'corners': set([(0, 1), (0, 2), (1, 1), (1, 2)])}),
- ((0, 2), {'value': 0,
+ ((0, 2), {'value': 1,
'corners': set([(0, 2), (0, 3), (1, 2), (1, 3)])}),
- ((1, 0), {'value': 0,
+ ((1, 0), {'value': 1,
'corners': set([(1, 0), (1, 1), (2, 0), (2, 1)])}),
- ((1, 1), {'value': 1,
+ ((1, 1), {'value': 0,
'corners': set([(1, 1), (1, 2), (2, 1), (2, 2)])}),
- ((1, 2), {'value': 0,
+ ((1, 2), {'value': 1,
'corners': set([(1, 2), (1, 3), (2, 2), (2, 3)])}),
- ((2, 0), {'value': 0,
+ ((2, 0), {'value': 1,
'corners': set([(2, 0), (2, 1), (3, 0), (3, 1)])}),
- ((2, 1), {'value': 0,
+ ((2, 1), {'value': 1,
'corners': set([(2, 1), (2, 2), (3, 1), (3, 2)])}),
- ((2, 2), {'value': 1,
+ ((2, 2), {'value': 0,
'corners': set([(2, 2), (2, 3), (3, 2), (3, 3)])}),
- ((3, 0), {'value': 0,
+ ((3, 0), {'value': 1,
'corners': set([(3, 0), (3, 1), (4, 0), (4, 1)])}),
- ((3, 1), {'value': 0,
+ ((3, 1), {'value': 1,
'corners': set([(3, 1), (3, 2), (4, 1), (4, 2)])}),
- ((3, 2), {'value': 1,
+ ((3, 2), {'value': 0,
'corners': set([(3, 2), (3, 3), (4, 2), (4, 3)])}),
])
tg.add_edges_from([
Please sign in to comment.
Something went wrong with that request. Please try again.