224 lines
10 KiB
Python
224 lines
10 KiB
Python
# 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 = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="%d %d %d %d" width="%d" height="%d">' % (x0, y0, x1 - x0, y1 - y0, x1 - x0, y1 - y0),
|
|
' <!-- Singe dragon, reconstructed as polygons from Dragon.jpeg; transparent background. -->',
|
|
' <defs>']
|
|
for i, (name, mask, verts, (p0, p1, c0, c1)) in enumerate(pieces):
|
|
svg.append(' <linearGradient id="g%d" gradientUnits="userSpaceOnUse" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"><stop offset="0" stop-color="%s"/><stop offset="1" stop-color="%s"/></linearGradient>' % (i, p0[0], p0[1], p1[0], p1[1], hexcol(c0), hexcol(c1)))
|
|
svg.append(' <filter id="shadow" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="3" dy="5" stdDeviation="4" flood-color="#000" flood-opacity="0.18"/></filter>')
|
|
# Brushed metal: horizontally stretched noise, clipped to the tile and blended in lightly.
|
|
svg.append(' <filter id="brush" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.003 0.7" numOctaves="2" seed="7" result="noise"/><feColorMatrix in="noise" type="saturate" values="0" result="grey"/><feComponentTransfer in="grey" result="grain"><feFuncA type="linear" slope="0.22"/></feComponentTransfer><feComposite in="grain" in2="SourceGraphic" operator="in" result="clipped"/><feBlend in="clipped" in2="SourceGraphic" mode="overlay"/></filter>')
|
|
svg.append(' </defs>')
|
|
svg.append(' <title>Singe dragon</title>')
|
|
svg.append(' <g filter="url(#shadow)">')
|
|
# 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(' <polygon points="%s" fill="url(#g%d)" fill-opacity="0.88" stroke="#c2ccc6" stroke-width="2" stroke-linejoin="round"/>' % (points, i))
|
|
else:
|
|
rim = hexcol((np.maximum(grad[2], grad[3]) + 255) / 2)
|
|
svg.append(' <polygon points="%s" fill="url(#g%d)" stroke="%s" stroke-width="1.5" stroke-linejoin="round" filter="url(#brush)"/>' % (points, i, rim))
|
|
svg.append(' </g>')
|
|
svg.append('</svg>')
|
|
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)
|