# Reconstructs assets/Dragon.jpeg as assets/Dragon.svg: segments the tiles by material, fits each as a # convex polygon, samples a linear gradient per tile, and writes an SVG with a transparent background. # Usage: python3 util/traceDragon.py assets/Dragon.jpeg assets/Dragon.svg [--debug x.png] [--preview x.png] [--pieces x.json] # --pieces writes the tile polygons and colours for util/dragonObj.py. Needs numpy, scipy and Pillow. import argparse import json import numpy as np from PIL import Image, ImageDraw from scipy import ndimage from scipy.spatial import ConvexHull parser = argparse.ArgumentParser() parser.add_argument('src') parser.add_argument('out') parser.add_argument('--debug') parser.add_argument('--preview') parser.add_argument('--pieces') args = parser.parse_args() src = args.src out = args.out debug = args.debug preview = args.preview im = np.asarray(Image.open(src).convert('RGB')).astype(float) h, w, _ = im.shape r, g, b = im[..., 0], im[..., 1], im[..., 2] bright = im.mean(axis=2) chroma = im.max(axis=2) - im.min(axis=2) warm = r - b # Everything that is not the white background; glass interiors that reach white are holes to fill. nonbg = (bright < 246) | (chroma > 14) nonbg = ndimage.binary_opening(nonbg, iterations=1) nonbg = ndimage.binary_fill_holes(nonbg) dark = nonbg & (bright < 185) & (warm < 10) champagne = nonbg & (warm >= 8) & (bright < 242) glass = nonbg & ~dark & ~champagne # Edges between tiles: the brightness changes sharply along every tile boundary, whatever the # materials either side, so cores are the class masks with the edges cut out. sx = ndimage.sobel(bright, axis=1) sy = ndimage.sobel(bright, axis=0) edges = np.hypot(sx, sy) > 60 edges = ndimage.binary_dilation(edges, iterations=1) def crisp(piece): # Tiles have hard edges all round; the floor shadows fade out softly. ring = ndimage.binary_dilation(piece, iterations=3) & ~piece return (ring & edges).sum() / max(1, ring.sum()) def tiles(mask, erode, minArea, taken): found = [] for erodeNow, areaNow in ((erode, minArea), (0, 120)): core = ndimage.binary_erosion(mask & ~edges, iterations=erodeNow) if erodeNow else (mask & ~edges) labels, count = ndimage.label(core) for index in range(1, count + 1): piece = labels == index if piece.sum() < areaNow: continue if (piece & taken).any(): continue if crisp(piece) < 0.35: continue # ...and they are not long thin smears either. ys, xs = np.nonzero(piece) span = max(xs.max() - xs.min(), ys.max() - ys.min()) if span / max(1.0, ndimage.distance_transform_edt(piece).max()) > 20: continue # Grow the core back out within the class mask, but not into a neighbouring core. grown = ndimage.binary_dilation(piece, iterations=erodeNow + 2) & mask taken |= grown found.append(grown) return found def lineMeet(a0, a1, b0, b1): da = a1 - a0 db = b1 - b0 den = da[0] * db[1] - da[1] * db[0] if abs(den) < 1e-9: return None t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den return a0 + da * t def polygon(mask): ys, xs = np.nonzero(mask) pts = np.column_stack([xs, ys]).astype(float) hull = ConvexHull(pts) verts = pts[hull.vertices] # Drop near-collinear hull vertices until the corners remain. changed = True while changed and len(verts) > 3: changed = False best = None for i in range(len(verts)): p0, p1, p2 = verts[i - 1], verts[i], verts[(i + 1) % len(verts)] d = p2 - p0 n = np.hypot(*d) if n == 0: continue dist = abs(d[0] * (p0[1] - p1[1]) - d[1] * (p0[0] - p1[0])) / n if best is None or dist < best[0]: best = (dist, i) if best and best[0] < 3.5: verts = np.delete(verts, best[1], axis=0) changed = True # The bevelled rims clip the sharp tips off the class masks; a short edge between two long # ones is such a clipped tip, so extend the neighbours until they meet. changed = True while changed and len(verts) > 3: changed = False n = len(verts) lengths = [np.hypot(*(verts[(i + 1) % n] - verts[i])) for i in range(n)] i = int(np.argmin(lengths)) if lengths[i] < 16: a0, a1 = verts[i - 1], verts[i] b0, b1 = verts[(i + 2) % n], verts[(i + 1) % n] meet = lineMeet(a0, a1, b0, b1) if meet is not None and np.hypot(*(meet - a1)) < 60: verts[i] = meet verts = np.delete(verts, (i + 1) % n, axis=0) changed = True # Push every edge outward so tiles reach their rims instead of stopping at the flat face. n = len(verts) area = 0.5 * sum(verts[i][0] * verts[(i + 1) % n][1] - verts[(i + 1) % n][0] * verts[i][1] for i in range(n)) sign = 1.0 if area > 0 else -1.0 grow = 4.0 shifted = [] for i in range(n): a, b = verts[i], verts[(i + 1) % n] d = b - a d = d / np.hypot(*d) normal = np.array([d[1], -d[0]]) * sign shifted.append((a + normal * grow, b + normal * grow)) result = [] for i in range(n): meet = lineMeet(*shifted[i - 1], *shifted[i]) result.append(meet if meet is not None else verts[i]) return np.array(result) def gradient(mask, verts): ys, xs = np.nonzero(mask) inner = ndimage.binary_erosion(mask, iterations=3) if inner.sum() > 50: ys, xs = np.nonzero(inner) cols = im[ys, xs] br = cols.mean(axis=1) # Direction of the brightness trend across the tile. A = np.column_stack([xs, ys, np.ones_like(xs)]).astype(float) coef, *_ = np.linalg.lstsq(A, br, rcond=None) d = np.array([coef[0], coef[1]]) if np.hypot(*d) < 1e-6: d = np.array([1.0, 0.0]) d = d / np.hypot(*d) proj = xs * d[0] + ys * d[1] lo, hi = np.percentile(proj, 5), np.percentile(proj, 95) c0 = cols[proj <= lo + (hi - lo) * 0.15].mean(axis=0) c1 = cols[proj >= hi - (hi - lo) * 0.15].mean(axis=0) centre = np.array([xs.mean(), ys.mean()]) p0 = centre + d * (lo - proj.mean()) p1 = centre + d * (hi - proj.mean()) return p0, p1, c0, c1 def hexcol(c): return '#%02x%02x%02x' % tuple(int(max(0, min(255, v))) for v in c) pieces = [] taken = np.zeros_like(nonbg) for name, mask, erode, minArea in (('dark', dark, 2, 300), ('champagne', champagne, 2, 300), ('glass', glass, 2, 300)): for t in tiles(mask, erode, minArea, taken): verts = polygon(t) pieces.append((name, t, verts, gradient(t, verts))) print('pieces', len(pieces), {n: sum(1 for p in pieces if p[0] == n) for n in ('dark', 'champagne', 'glass')}) # Bounds with a margin. allv = np.vstack([p[2] for p in pieces]) x0, y0 = np.floor(allv.min(axis=0)) - 8 x1, y1 = np.ceil(allv.max(axis=0)) + 8 svg = ['', '' % (x0, y0, x1 - x0, y1 - y0, x1 - x0, y1 - y0), ' ', ' '] for i, (name, mask, verts, (p0, p1, c0, c1)) in enumerate(pieces): svg.append(' ' % (i, p0[0], p0[1], p1[0], p1[1], hexcol(c0), hexcol(c1))) svg.append(' ') # Brushed metal: horizontally stretched noise, clipped to the tile and blended in lightly. svg.append(' ') svg.append(' ') svg.append(' Singe dragon') svg.append(' ') # Large tiles first so any small tile that sits on a neighbour (the eye) stays visible. for i, (name, mask, verts, grad) in sorted(enumerate(pieces), key=lambda e: -e[1][1].sum()): points = ' '.join('%.1f,%.1f' % (x, y) for x, y in verts) if name == 'glass': svg.append(' ' % (points, i)) else: rim = hexcol((np.maximum(grad[2], grad[3]) + 255) / 2) svg.append(' ' % (points, i, rim)) svg.append(' ') svg.append('') open(out, 'w').write('\n'.join(svg) + '\n') if preview: pv = Image.new('RGB', (w, h), (200, 200, 200)) pd = ImageDraw.Draw(pv) for name, mask, verts, (p0, p1, c0, c1) in sorted(pieces, key=lambda e: -e[1].sum()): mid = tuple(int(v) for v in (c0 + c1) / 2) pd.polygon([tuple(v) for v in verts], fill=mid, outline=(90, 90, 90) if name != 'glass' else (170, 185, 178)) pv.save(preview) if debug: dbg = Image.open(src).convert('RGB') draw = ImageDraw.Draw(dbg) for name, mask, verts, grad in pieces: colour = {'dark': (255, 0, 0), 'champagne': (0, 160, 0), 'glass': (0, 90, 255)}[name] pts = [tuple(v) for v in verts] + [tuple(verts[0])] draw.line(pts, fill=colour, width=2) dbg.save(debug) if args.pieces: data = {'width': w, 'height': h, 'bounds': [float(x0), float(y0), float(x1), float(y1)], 'pieces': []} for name, mask, verts, (p0, p1, c0, c1) in pieces: data['pieces'].append({'material': name, 'points': [[round(float(x), 2), round(float(y), 2)] for x, y in verts], 'colour0': [int(v) for v in c0], 'colour1': [int(v) for v in c1]}) json.dump(data, open(args.pieces, 'w'), indent=1)