HatchLing / ling_tiles.py
taellinglin's picture
Initial commit: Ling hatch with red/blue/green/black Gradio theme
68972cd
Raw History Blame Contribute Delete
38.3 kB
#!/usr/bin/env python3
"""Ling tiles: a picture as a packing of square tiles and circles of ring text.
The picture is posterized into the seven Ling hues and up to four densities (as in ling_hatch).
Each region is then packed, largest first, with circles and axis-aligned squares that never cross a
region edge. Flat areas take large pieces and detailed areas small ones, so the packing itself draws
the picture. Tone is carried by how much line each piece holds.
circle rings of text spelling the hue's key (asm, lit, bind, ctl, call, fn, mod), set in Chesilin, the Ling
ring face, one glyph per ring cell (or in Ling Ring with --ring-face lingring);
the hub holds the hue's node shape. Light tones: outlined letters. Dark tones: concentric
line hatch with the letters knocked out.
tile a square hatched in the hue's signature family at the region's density; larger tiles carry
the hue's spectrum letter (R O Y G B I V) in Daemon, the Ling tile face (or --tile-face lingring).
python ling_tiles.py photo.jpg --out out
"""
from __future__ import annotations
import argparse, colorsys, hashlib, json, math, re, zipfile
from dataclasses import dataclass, asdict, fields
from pathlib import Path
import cv2
import numpy as np
from fontTools.pens.recordingPen import RecordingPen
from fontTools.ttLib import TTFont
import ling_hatch as lh
from ling_hatch import KEYS, EMPTY, THEMES
HERE = Path(__file__).resolve().parent
CAP, CELL = 700, 800 # Ling Ring: cap height and the one cell width, font units
SPECTRUM = 'ROYGBIV'
SHAPES = ['octagon', 'cross', 'circle', 'triangle', 'diamond', 'hexagon', 'square'] # node shape per hue
@dataclass
class TileOptions:
circle_min_mm: float = 1.0 # smallest circle radius; circles too small for a ring of text show the hue's node shape
circle_max_mm: float = 15.0 # largest circle radius
tile_min_mm: float = 0.4 # smallest tile side
tile_max_mm: float = 24.0 # largest tile side
gap_mm: float = 0.12 # clear space between pieces
mix: float = 1.0 # 0: whichever shape covers more .. 2: strong patches of tiles and of circles
circle_bias: float = 1.35 # above 1 favours circles everywhere, below 1 favours tiles
mix_patch_mm: float = 22.0 # size of those patches
density_sizing: float = 0.5 # 0: piece size ignores tone .. 1: the densest tone gets pieces a quarter the size
cap_min_mm: float = 1.2 # smallest ring text (cap height)
cap_max_mm: float = 3.6 # largest ring text
letter_tile_mm: float = 4.2 # tiles at least this wide carry a spectrum letter
ring_face: str = 'chesilin' # face of the ring text: chesilin (the Ling ring face) or lingring
tile_face: str = 'daemon' # face of the tile letters: daemon (the Ling tile face) or lingring
ring_text: str = '' # empty: each ring spells its hue key; otherwise this phrase on every ring
animate: bool = False # screen version: rings turn, glyphs flash, hatch flows, colours pulse (CSS in the SVG)
chase_min_mm: float = 4.0 # circles at least this radius light their outer ring glyph by glyph
pulse_tile_mm: float = 2.0 # tiles at least this wide take part in the wave of lit frames
# --------------------------------------------------------------------------- Ling Ring outlines
_GLYPHS: dict = {}
def glyph(ch: str):
"""Contours of one Ling Ring character in font units -> list of (n, 2) arrays."""
if not _GLYPHS:
f = TTFont(str(HERE / 'LingRing-Variable.ttf')); gs = f.getGlyphSet(); cm = f.getBestCmap()
for cp, name in cm.items():
pen = RecordingPen(); gs[name].draw(pen); out, cur = [], []
for op, a in pen.value:
if op == 'moveTo': cur = [a[0]]
elif op == 'lineTo': cur.append(a[0])
elif op in ('closePath', 'endPath'):
if len(cur) > 2: out.append(np.array(cur, float))
cur = []
_GLYPHS[chr(cp)] = out
return _GLYPHS.get(ch, _GLYPHS.get('·', []))
_LING: dict = {}
def ling_glyph(face: str, ch: str):
"""Daemon or Chesilin glyph from the Ling bundle -> (width, height, contours), y down, in its own bounding box."""
if not _LING:
src = (HERE / 'ling_bundle.js').read_text(encoding='utf-8')
G = json.loads(re.search(r'var GLYPHS = (\{.*?\});\n', src, re.S).group(1))
for f, d in G.items():
_LING[f] = {c: (g[0], g[1], [np.array(p, float).reshape(-1, 2) for p in g[2]]) for c, g in d.items()}
return _LING.get(face, {}).get(ch)
def clean_text(t: str, face: str = 'lingring') -> str:
if face == 'lingring':
glyph('A')
return ''.join(c if c in _GLYPHS else ' ' for c in t.upper())
return ''.join(c if ling_glyph(face, c) else ' ' for c in t)
def _spans(contours, y: float):
"""x-intervals where the horizontal line at height y is inside the contours (even-odd)."""
xs = []
for c in contours:
a = c; b = np.roll(c, -1, axis=0)
m = (a[:, 1] > y) != (b[:, 1] > y)
if m.any():
xs += list(a[m, 0] + (y - a[m, 1]) * (b[m, 0] - a[m, 0]) / (b[m, 1] - a[m, 1]))
xs.sort()
return [(xs[i], xs[i + 1]) for i in range(0, len(xs) - 1, 2)]
# --------------------------------------------------------------------------- packing
def _noise(shape, cell_px, seed):
"""Smooth field in [-1, 1]: decides where tiles are favoured and where circles are."""
h, w = shape; rng = np.random.default_rng(seed)
g = rng.uniform(-1, 1, (max(2, int(h / cell_px) + 3), max(2, int(w / cell_px) + 3))).astype(np.float32)
big = cv2.resize(g, (int(g.shape[1] * cell_px), int(g.shape[0] * cell_px)), interpolation=cv2.INTER_CUBIC)
return np.clip(big[:h, :w] * 1.4, -1, 1)
_DISKS: dict = {}
K3 = np.ones((3, 3), np.uint8)
def _disk(r):
if r not in _DISKS:
y, x = np.ogrid[-r:r + 1, -r:r + 1]; _DISKS[r] = (x * x + y * y) <= r * r
return _DISKS[r]
def pack(lbl: np.ndarray, px_per_mm: float, t: TileOptions, seed: int = 0, progress=None, n_steps: int = 4) -> list[dict]:
"""Greedy largest-first packing of circles and squares inside the label regions.
Every piece lies wholly inside one (hue, density) region and keeps gap_mm from its neighbours, so
piece size follows local detail: the distance to the nearest region edge or neighbour.
-> [{'kind': 'c'|'s', 'x', 'y', 'r' (radius or half side, mask px), 'label'}]
"""
h, w = lbl.shape
r_min, r_max = max(4, round(t.circle_min_mm * px_per_mm)), max(5, round(t.circle_max_mm * px_per_mm))
a_min, a_max = max(2, round(t.tile_min_mm * px_per_mm / 2)), max(3, round(t.tile_max_mm * px_per_mm / 2))
gap = max(1, round(t.gap_mm * px_per_mm)); g2 = max(1, gap // 2)
edge = np.zeros((h, w), bool)
edge[:, 1:] |= lbl[:, 1:] != lbl[:, :-1]; edge[1:, :] |= lbl[1:, :] != lbl[:-1, :]
edge |= lbl == EMPTY
occ = cv2.dilate(edge.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * g2 + 1, 2 * g2 + 1))).astype(bool)
occ[:g2, :] = True; occ[-g2:, :] = True; occ[:, :g2] = True; occ[:, -g2:] = True
bias = t.circle_bias * (np.exp2(t.mix * 1.6 * _noise((h, w), t.mix_patch_mm * px_per_mm, seed)) if t.mix > 0 else 1.0)
a_floor = min(math.pi * r_min ** 2, 4.0 * a_min ** 2)
# density-aware sizing: denser tones are capped at smaller pieces, so they also carry more outline
k = np.clip(1.0 - 0.75 * t.density_sizing * (lbl % 4) / max(n_steps - 1, 1), 0.2, 1.0).astype(np.float32)
r_cap = np.maximum(np.floor(r_max * k), r_min + 2); a_cap = np.maximum(np.floor(a_max * k), a_min + 2)
out = []; ceiling = float('inf')
for rnd in range(400):
free = (~occ).astype(np.uint8)
d2 = cv2.distanceTransform(free, cv2.DIST_L2, cv2.DIST_MASK_PRECISE); dc = cv2.distanceTransform(free, cv2.DIST_C, 3)
r = np.minimum(np.floor(d2 - 1.0), r_cap); a = np.minimum(np.floor(dc - 1.0), a_cap)
ac = np.where(r >= r_min, math.pi * r * r, 0.0) * bias # circles compete on (biased) area
asq = np.where(a >= a_min, 4.0 * a * a, 0.0)
best = np.minimum(np.maximum(ac, asq), ceiling); top = float(best.max())
if top < a_floor: break
thr = max(top * 0.55, a_floor)
# candidates: ridge points of the distance field; on flat plateaus (pieces at their size cap) a coarse grid
step = max(1, int(math.sqrt(thr) / 8))
cand = (best >= thr) & (best >= cv2.dilate(best.astype(np.float32), K3))
if step > 1:
capped = (r >= r_cap) | (a >= a_cap)
grid = np.zeros((h, w), bool); grid[::step, ::step] = True
cand &= ~capped | grid
ys, xs = np.nonzero(cand)
order = np.argsort(-best[ys, xs], kind='stable')
placed = 0
for i in order:
y, x = int(ys[i]), int(xs[i])
if ac[y, x] >= asq[y, x]:
rr = int(r[y, x])
if y - rr < 0 or x - rr < 0 or y + rr >= h or x + rr >= w: continue
for _ in range(3): # stale distances: give a little before giving up
if not occ[y - rr:y + rr + 1, x - rr:x + rr + 1][_disk(rr)].any(): break
rr -= max(1, rr // 40)
else: continue
if rr < r_min: continue
out.append({'kind': 'c', 'x': x, 'y': y, 'r': rr, 'label': int(lbl[y, x])})
R = rr + gap; y0, y1, x0, x1 = max(0, y - R), min(h, y + R + 1), max(0, x - R), min(w, x + R + 1)
occ[y0:y1, x0:x1] |= _disk(R)[y0 - (y - R):y1 - (y - R), x0 - (x - R):x1 - (x - R)]
else:
aa = int(a[y, x])
if y - aa < 0 or x - aa < 0 or y + aa >= h or x + aa >= w: continue
if occ[y - aa:y + aa + 1, x - aa:x + aa + 1].any(): continue
out.append({'kind': 's', 'x': x, 'y': y, 'r': aa, 'label': int(lbl[y, x])})
A = aa + gap
occ[max(0, y - A):y + A + 1, max(0, x - A):x + A + 1] = True
placed += 1
if progress and rnd % 4 == 0: progress('packing: %d pieces' % len(out))
if placed == 0: # nothing fitted at this size: look lower
ceiling = thr * 0.8
if ceiling < a_floor: break
return out
# --------------------------------------------------------------------------- drawing
def _ints(pts):
p = np.rint(np.asarray(pts)).astype(int)
d = np.diff(p, axis=0)
return 'M%d %d' % (p[0, 0], p[0, 1]) + ''.join('l%d %d' % (a, b) for a, b in d if a or b) + 'z'
def _open(pts):
p = np.rint(np.asarray(pts)).astype(int); d = np.diff(p, axis=0)
return 'M%d %d' % (p[0, 0], p[0, 1]) + ''.join('l%d %d' % (a, b) for a, b in d if a or b)
def _circle_d(cx, cy, r):
return 'M%d %dA%d %d 0 1 1 %d %dA%d %d 0 1 1 %d %d' % (cx - r, cy, r, r, cx + r, cy, r, r, cx - r, cy)
def _arc_d(cx, cy, rho, a0, a1):
"""Arc at radius rho from angle a0 to a1 (radians clockwise from the top, a1 > a0)."""
x0, y0 = cx + rho * math.sin(a0), cy - rho * math.cos(a0); x1, y1 = cx + rho * math.sin(a1), cy - rho * math.cos(a1)
return 'M%d %dA%d %d 0 %d 1 %d %d' % (round(x0), round(y0), round(rho), round(rho), 1 if a1 - a0 > math.pi else 0, round(x1), round(y1))
def _node(kind, cx, cy, r):
if kind == 'circle': return _circle_d(round(cx), round(cy), round(r))
if kind == 'cross':
k = r * 0.38
pts = [(-k, -r), (k, -r), (k, -k), (r, -k), (r, k), (k, k), (k, r), (-k, r), (-k, k), (-r, k), (-r, -k), (-k, -k)]
else:
n, rot = {'octagon': (8, 22.5), 'triangle': (3, 0), 'diamond': (4, 0), 'hexagon': (6, 30), 'square': (4, 45)}[kind]
pts = [(r * math.sin(math.radians(rot + i * 360 / n)), -r * math.cos(math.radians(rot + i * 360 / n))) for i in range(n)]
return _ints([(cx + x, cy + y) for x, y in pts] )
def _hatch_polys(contours, angle_deg, sp):
"""Parallel hatch inside the contours (even-odd) -> [((x0, y0), (x1, y1)), ...]"""
t = math.radians(angle_deg); c, s_ = math.cos(t), math.sin(t)
R = [np.stack([p[:, 0] * c - p[:, 1] * s_, p[:, 0] * s_ + p[:, 1] * c], 1) for p in contours]
lo = min(p[:, 1].min() for p in R); hi = max(p[:, 1].max() for p in R); out = []
for k in range(int(math.floor(lo / sp)), int(math.ceil(hi / sp)) + 1):
v = (k + 0.5) * sp
if v <= lo or v >= hi: continue
for a, b in _spans(R, v):
if b - a > 1.0: out.append(((a * c + v * s_, -a * s_ + v * c), (b * c + v * s_, -b * s_ + v * c)))
return out
def _ring_cells(face, txt, rm, band, m):
"""Glyphs of one ring, each in the frame of its own cell: u along the arc from the cell centre, v outward
from mid-band. Chesilin and Daemon are fitted to the cell by their own bounding box (70% of the arc width,
62% of the band), as the Ling system sets ring text. -> [(contours, umin, umax, vmin, vmax) or None]"""
A = 2 * math.pi * rm / m; out = []
for ch in txt:
cs = None
if ch != ' ':
if face == 'lingring':
k = 0.62 * band / CAP
cs = [np.stack([(c[:, 0] - CELL / 2) * k, (c[:, 1] - CAP / 2) * k], 1) for c in glyph(ch)]
else:
g = ling_glyph(face, ch)
if g:
w, h, polys = g; k = min(0.7 * A / w, 0.62 * band / h)
cs = [np.stack([(c[:, 0] - w / 2) * k, (h / 2 - c[:, 1]) * k], 1) for c in polys]
if cs:
allp = np.vstack(cs)
out.append((cs, allp[:, 0].min(), allp[:, 0].max(), allp[:, 1].min(), allp[:, 1].max()))
else:
out.append(None)
return out
def _polar(cx, cy, rm, th, p0, p1, closed_tail=True):
"""Points of the segment p0 -> p1 (cell frame) bent onto the ring, subdivided so it follows the arc."""
k = max(1, int(math.ceil(abs(p1[0] - p0[0]) / rm / math.radians(3.0)))); pts = []
for q in range(k):
f = q / k; uu = p0[0] + (p1[0] - p0[0]) * f; vv = p0[1] + (p1[1] - p0[1]) * f
phi = th + uu / rm; rho = rm + vv
pts.append((cx + rho * math.sin(phi), cy - rho * math.cos(phi)))
return pts
def draw_circle(e, sc, geo, t: TileOptions, u, key, hue_i, step, n_steps, rings_out=None):
"""-> (paths at line width, {stroke width: paths}) for one circle of ring text.
With rings_out (a list), everything that must turn with a ring is appended there instead, one dict per ring,
and the hub's node shape is returned in it as {'node': d}."""
cx, cy, R = e['x'] * sc + sc // 2, e['y'] * sc + sc // 2, e['r'] * sc
line = geo['line']; lines, text = [_circle_d(cx, cy, R)], {}
face = t.ring_face if t.ring_face in ('chesilin', 'daemon') else 'lingring'
cap = min(max(0.22 * R, t.cap_min_mm * u), t.cap_max_mm * u)
band = cap / 0.62
if face == 'lingring':
unit = (clean_text(t.ring_text, face) if t.ring_text else key.upper()) + '·'; cell = CELL * cap / CAP
else: # Ling labels are lowercase; an empty cell separates the words
unit = (clean_text(t.ring_text, face) if t.ring_text else key) + ' '; cell = 0.5 * band
dark = step >= n_steps - 2 and n_steps > 2 or (n_steps == 2 and step == 1)
mid = step == n_steps - 2 and n_steps > 2
max_rings = 1 if step == 0 and not dark else 4
dw = max(3, round(0.75 * line))
ro = R - 0.5 * line; rings = 0
while rings < max_rings:
ri = ro - band; rm = ri + band / 2
n = int(2 * math.pi * rm / cell)
if ri < 0.3 * band or n < len(unit): break
txt = unit * (n // len(unit)); m = len(txt); dth = 2 * math.pi / m
cells = _ring_cells(face, txt, rm, band, m)
outer_lines, outer_text = lines, text
ring = {'cx': cx, 'cy': cy, 'R': R, 'rm': rm, 'index': rings, 'lines': [], 'text': {}, 'glyphs': []}
if rings_out is not None: lines, text = ring['lines'], ring['text']
for i, g in enumerate(cells):
if not g: continue
cs, u0, u1, v0, v1 = g; th = (i + 0.5) * dth; gh = v1 - v0; gd = []
tw = line if gh >= 10 * line else max(4, round(0.7 * line))
for c in cs:
pts = []
for j in range(len(c)): pts += _polar(cx, cy, rm, th, c[j], c[(j + 1) % len(c)])
gd.append(_ints(pts + pts[:1]))
if rings_out is not None: ring['glyphs'].append((tw, i / m, ''.join(gd)))
else: text.setdefault(tw, []).extend(gd)
sp = 0.09 * gh # glyph interiors: single-angle hatch, where it is wide enough to print
if face != 'lingring' and not dark and sp >= 1.6 * line:
hw = int(round(min(line, sp / 2)))
for p0, p1 in _hatch_polys(cs, 45, sp):
end = (cx + (rm + p1[1]) * math.sin(th + p1[0] / rm), cy - (rm + p1[1]) * math.cos(th + p1[0] / rm))
text.setdefault(hw, []).append(_open(_polar(cx, cy, rm, th, p0, p1) + [end]))
if face != 'lingring' and not dark: # ring cells: one glyph per annular cell
for i in range(m):
a = i * dth
text.setdefault(dw, []).append('M%d %dL%d %d' % (round(cx + ri * math.sin(a)), round(cy - ri * math.cos(a)),
round(cx + ro * math.sin(a)), round(cy - ro * math.cos(a))))
if dark: # concentric hatch, letters knocked out
sp = geo['base2'] * (2 if mid else 1)
mg = 1.4 * line # clear margin round the letters
nl = int((band - line) / sp)
for q in range(nl):
rho = ri + line * 0.5 + (q + 0.5) * (band - line) / max(nl, 1)
v = rho - rm; voids = []
for i, g in enumerate(cells):
if not g: continue
cs, u0, u1, v0, v1 = g
if v < v0 - mg or v > v1 + mg: continue
if mid: # mid tone: each letter sits on a clear plate
iv = [(u0, u1)]
else: # dark tone: the hatch runs up to every stroke
iv = []
for vv in (v - mg, v, v + mg):
iv += _spans(cs, min(max(vv, v0 + 0.37), v1 - 0.37) + 0.011)
thc = (i + 0.5) * dth
for a0, a1 in iv: voids.append((thc + (a0 - mg) / rm, thc + (a1 + mg) / rm))
if not voids:
outer_lines.append(_circle_d(cx, cy, round(rho))); continue
voids.sort(); merged = [list(voids[0])]
for a0, a1 in voids[1:]:
if a0 <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], a1)
else: merged.append([a0, a1])
first = merged[0][0] + 2 * math.pi
for k2, (a0, a1) in enumerate(merged):
nxt = merged[k2 + 1][0] if k2 + 1 < len(merged) else first
if (nxt - a1) * rho > 1.5 * line:
lines.append(_arc_d(cx, cy, rho, a1, nxt))
lines, text = outer_lines, outer_text
if rings_out is not None: rings_out.append(ring)
lines.append(_circle_d(cx, cy, round(ri)))
ro = ri; rings += 1
hub = ro if rings else R
if not rings and step > 0: # too small for text: concentric lines carry the tone
sp = geo['base2'] * geo['mults'][step]; rho = R - 0.5 * line - sp
while rho > 0.66 * R and rho > 2 * line:
lines.append(_circle_d(cx, cy, round(rho))); rho -= sp
if hub > 0.5 * u:
nd = _node(SHAPES[hue_i], cx, cy, 0.58 * hub)
if rings_out is not None and rings: rings_out.append({'node': nd, 'cx': cx, 'cy': cy, 'R': R})
else: lines.append(nd)
return lines, text
def _tile_glyph(face, ch, cx, cy, S):
"""The tile's letter, filling 60% of the tile and centred on its own bounding box. Device px, y down."""
if face != 'lingring':
g = ling_glyph(face, ch)
if g:
w, h, polys = g; k = 0.6 * S / max(w, h)
return [np.stack([cx + (c[:, 0] - w / 2) * k, cy + (c[:, 1] - h / 2) * k], 1) for c in polys]
k = 0.6 * S / CAP
return [np.stack([cx + (c[:, 0] - CELL / 2) * k, cy - (c[:, 1] - CAP / 2) * k], 1) for c in glyph(ch)]
def draw_tiles(tiles, lbl_shape, sc, geo, t: TileOptions, u, params, theme):
"""-> (still svg, moving svg, hatch runs) for all tiles: signature hatch (clipped on a raster), exact outlines,
letters. Everything animated is returned apart so it can be drawn after all the still line work."""
raster = np.full(lbl_shape, EMPTY, np.uint8)
out_d = {k: [] for k in KEYS}; let_d = {k: [] for k in KEYS}; br_d = {k: [] for k in KEYS}; gh_d = {k: [] for k in KEYS}
spin = {k: [] for k in KEYS} # animated: (cx, cy, outline d, hatch d) per lettered tile
big = {k: [] for k in KEYS} # animated: (x, frame d) of tiles large enough to light up
gw = max(3, round(0.6 * geo['line']))
for e in tiles:
x, y, a = e['x'], e['y'], e['r']; h_i = e['label'] // 4; key = KEYS[h_i]
raster[y - a:y + a + 1, x - a:x + a + 1] = e['label']
X0, Y0, S = (x - a) * sc, (y - a) * sc, (2 * a + 1) * sc
out_d[key].append('M%d %dh%dv%dh%dz' % (X0, Y0, S, S, -S))
if t.animate and S >= t.pulse_tile_mm * u: big[key].append((X0, out_d[key][-1]))
if S >= t.letter_tile_mm * u:
polys = _tile_glyph(t.tile_face, SPECTRUM[h_i], X0 + S / 2, Y0 + S / 2, S) # the glyph fills 60% of its tile
ld = [_ints(np.vstack([p, p[:1]])) for p in polys]; hd = []
if t.tile_face != 'lingring': # glyph interior: single-angle hatch
allp = np.vstack(polys); sp = max(0.09 * (allp[:, 1].max() - allp[:, 1].min()), 1.6 * geo['line'])
hd = [_open([p0, p1]) for p0, p1 in _hatch_polys(polys, 45, sp)]
if t.animate: spin[key].append((X0 + S // 2, Y0 + S // 2, ''.join(ld), ''.join(hd)))
else: let_d[key].extend(ld); gh_d[key].extend(hd)
hole = np.zeros(lbl_shape, np.uint8)
cv2.fillPoly(hole, [np.rint(p / sc).astype(np.int32) for p in polys], 1)
ys, xs = slice(max(0, y - a), y + a + 1), slice(max(0, x - a), x + a + 1)
if t.animate: # the letter turns: keep a clear disc for it
cv2.circle(hole, (x, y), int(math.ceil(0.44 * S / sc)), 1, -1); raster[ys, xs][hole[ys, xs] > 0] = EMPTY
else:
r = max(1, round(geo['line'] * 1.6 / sc))
sub = cv2.dilate(hole[ys, xs], np.ones((2 * r + 1, 2 * r + 1), np.uint8))
raster[ys, xs][sub > 0] = EMPTY
i, arm = 0.055 * S, 0.21 * S # corner brackets, as on a Ling tile
x0, y0, x1, y1 = X0 + i, Y0 + i, X0 + S - i, Y0 + S - i
br_d[key].append('M%d %dv%dh%dM%d %dh%dv%dM%d %dv%dh%dM%d %dh%dv%d' % (
x0, y0 + arm, -arm, arm, x1 - arm, y0, arm, arm, x1, y1 - arm, arm, -arm, x0 + arm, y1, -arm, -arm))
dirs = [] if t.animate else None
frag, st = lh.hatch_picture(raster, params, theme, geo, dirs)
frag = re.sub(r'<g id="\w+-outline">.*?</g>', '', frag, flags=re.S) # outlines are drawn exactly, below
frag = re.sub(r'id="(\w+?)(-hatch)?"', lambda m: 'id="tile-%s%s"' % (m.group(1), m.group(2) or ''), frag)
th = THEMES[theme]; extra = []; anim = []
if t.animate: # each hue's second hatch direction fades out and back: the crosshatch opens and closes
frag = ''.join('<path stroke="%s" d="%s"/>' % (th[k], d) for k, fi, d in dirs if fi != 1)
anim.append('<g class="x">%s</g>' % ''.join('<path stroke="%s" d="%s"/>' % (th[k], d) for k, fi, d in dirs if fi == 1))
for k in KEYS:
if out_d[k]:
extra.append('<g id="tile-%s-frame" stroke="%s"><path d="%s"/>%s%s</g>' % (
k, th[k], ''.join(out_d[k]),
'<path d="%s" stroke-width="%d"/>' % (''.join(br_d[k]), round(geo['line'] * 1.4)) if br_d[k] else '',
('<path d="%s"/>' % ''.join(let_d[k]) if let_d[k] else '') +
('<path d="%s" stroke-width="%d"/>' % (''.join(gh_d[k]), gw) if gh_d[k] else '')))
if t.animate and big[k]: # larger tiles light up in a wave that crosses the sheet
anim.append('<g stroke="%s">%s</g>' % (lit_color(th[k], theme), ''.join(
'<path class="c" style="animation-delay:-%.1fs" d="%s"/>' % ((1 - X / (lbl_shape[1] * sc)) * PULSE_S + 0.4 * KEYS.index(k), d)
for X, d in big[k])))
for n, (cx, cy, ld, hd) in enumerate(spin[k]):
anim.append('<g class="t" stroke="%s" style="transform-origin:%dpx %dpx;animation-delay:-%.1fs"><path d="%s"/>%s</g>' % (
th[k], cx, cy, _rnd(cx, cy, 7) * 9.0, ld, '<path d="%s" stroke-width="%d"/>' % (hd, gw) if hd else ''))
return frag + ''.join(extra), ''.join(anim), st.get('hatch_runs', 0)
def _rnd(x, y, salt=0):
"""Repeatable pseudo-random number in [0, 1) from a position: gives each piece its own speed and phase."""
return (hashlib.blake2s(b'%d,%d,%d' % (int(x), int(y), salt), digest_size=4).digest()[0] * 256
+ hashlib.blake2s(b'%d,%d,%d' % (int(x), int(y), salt), digest_size=4).digest()[1]) / 65536.0
def _ring_svg(r, hue_i, t: TileOptions, u, lit):
"""One turning ring (or hub node) as an SVG group with its animation classes. `lit` is the hue's lit colour."""
cx, cy = r['cx'], r['cy']
if 'node' in r:
return '<path class="n" style="transform-origin:%dpx %dpx;animation-delay:-%.1fs" d="%s"/>' % (cx, cy, _rnd(cx, cy, 3) * 8, r['node'])
spd = int(_rnd(cx, cy, 1) * 4) + 2 * min(r['index'], 2) # inner rings turn faster
rev = (r['index'] + int(_rnd(cx, cy, 2) * 2)) % 2 # neighbouring rings turn opposite ways
by = {}
for w, f, d in r['glyphs']: by.setdefault(w, []).append(d)
for w, ds in by.items(): r['text'].setdefault(w, []).extend(ds)
body = ('<path d="%s"/>' % ''.join(r['lines']) if r['lines'] else '') + ''.join(
'<path d="%s" stroke-width="%d"/>' % (''.join(ds), w) for w, ds in sorted(r['text'].items()))
if r['index'] == 0 and r['R'] >= t.chase_min_mm * u: # a light runs round the outer ring, glyph by glyph
body += '<g stroke="%s">%s</g>' % (lit, ''.join('<path class="h" style="animation-delay:-%.2fs" stroke-width="%d" d="%s"/>' % (
(1 - f if rev else f) * CHASE_S, w, d) for w, f, d in r['glyphs']))
else: # the ring's glyphs spark together
body += ''.join('<path class="f" stroke="%s" style="animation-duration:%.1fs;animation-delay:-%.1fs" stroke-width="%d" d="%s"/>' % (
lit, 3 + 6 * _rnd(cx, cy, 4 + r['index']), 9 * _rnd(cx, cy, 5 + r['index']), w, ''.join(ds)) for w, ds in sorted(by.items()))
return '<g class="r d%d%s" style="transform-origin:%dpx %dpx">%s</g>' % (spd, ' cc' if rev else '', cx, cy, body)
PULSE_S = 8.0 # seconds for the wave of lit tile frames to cross the sheet
CHASE_S = 4.0 # seconds for the running light to go once round a ring
SPIN_S = [64, 48, 36, 27, 20, 15, 11, 8] # seconds per turn, slowest to fastest
def _shift(hexc, dl, ds=0.0):
"""The same hue, lighter or darker (and more or less saturated): hex -> hex."""
r, g, b = (int(hexc[i:i + 2], 16) / 255 for i in (1, 3, 5)); h, l, s_ = colorsys.rgb_to_hls(r, g, b)
r, g, b = colorsys.hls_to_rgb(h, min(1, max(0, l + dl)), min(1, max(0, s_ + ds)))
return '#%02x%02x%02x' % (round(r * 255), round(g * 255), round(b * 255))
def lit_color(base: str, theme: str) -> str:
"""A hue's lit colour: the same hue, lighter on black and deeper on white."""
return _shift(base, 0.27, 0.1) if theme == 'black' else _shift(base, -0.2, 0.12)
def animation_css(theme: str, geo: dict) -> str:
"""The <style> block of the animated sheet. Only transforms and fades are animated, which browsers can run
without redrawing the sheet. No filters, glows or blur; lit colours stay inside their own hue."""
css = ['.r{animation:lg-turn 30s linear infinite}.cc{animation-direction:reverse}',
'@keyframes lg-turn{to{transform:rotate(360deg)}}',
''.join('.d%d{animation-duration:%ds}' % (i, s_) for i, s_ in enumerate(SPIN_S)),
'.n{animation:lg-node 8s linear infinite}',
'@keyframes lg-node{0%{transform:rotate(0) scale(1)}50%{transform:rotate(180deg) scale(.62)}100%{transform:rotate(360deg) scale(1)}}',
'.t{animation:lg-flip 9s ease-in-out infinite}',
'@keyframes lg-flip{0%,72%{transform:rotate(0) scale(1)}86%{transform:rotate(180deg) scale(.7)}100%{transform:rotate(360deg) scale(1)}}',
'.f{opacity:0;animation:lg-spark 5s steps(1,end) infinite}',
'@keyframes lg-spark{0%,91%,94%,97%,100%{opacity:0}90%,93%,96%{opacity:1}}',
'.h{opacity:0;animation:lg-chase %gs steps(1,end) infinite}' % CHASE_S,
'@keyframes lg-chase{0%{opacity:1}9%,100%{opacity:0}}',
'.c{opacity:0;animation:lg-pulse %gs ease-in-out infinite}' % PULSE_S,
'@keyframes lg-pulse{0%,24%,100%{opacity:0}12%{opacity:1}}',
'.x{animation:lg-weave 10s ease-in-out infinite}',
'@keyframes lg-weave{0%,45%,100%{opacity:1}70%,80%{opacity:0}}',
'@media (prefers-reduced-motion:reduce){.r,.n,.t,.f,.h,.c,.x{animation:none}}']
return '<style>' + ''.join(css) + '</style>\n'
def picture(lbl, params, theme, geo, t: TileOptions, progress=None):
"""Pack and draw. -> (svg fragment in picture-local device px, stats)"""
sc, u = geo['sc'], geo['u']; n_steps = len(geo['mults'])
seed = int.from_bytes(hashlib.sha3_256(bytes.fromhex(params['signature'][:128])).digest()[:4], 'big')
els = pack(lbl, u / sc, t, seed, progress, n_steps)
tiles = [e for e in els if e['kind'] == 's']; circles = [e for e in els if e['kind'] == 'c']
if progress: progress('drawing %d tiles' % len(tiles))
frag, tail, runs = draw_tiles(tiles, lbl.shape, sc, geo, t, u, params, theme)
if progress: progress('setting %d rings' % len(circles))
th = THEMES[theme]; per = {k: ([], {}) for k in KEYS}
moving = {k: [] for k in KEYS}; n_turn = 0
for e in circles:
h_i, step = e['label'] // 4, e['label'] % 4; key = KEYS[h_i]
ro = [] if t.animate else None
ln, tx = draw_circle(e, sc, geo, t, u, key, h_i, step, n_steps, ro)
per[key][0].extend(ln)
for wdt, ds in tx.items(): per[key][1].setdefault(wdt, []).extend(ds)
for r in ro or []:
moving[key].append(_ring_svg(r, h_i, t, u, lit_color(th[key], theme))); n_turn += 'node' not in r
for k in KEYS:
ln, tx = per[k]
if ln or tx:
frag += '<g id="ring-%s" stroke="%s"><path d="%s"/>%s</g>' % (
k, th[k], ''.join(ln), ''.join('<path d="%s" stroke-width="%d"/>' % (''.join(ds), wdt) for wdt, ds in sorted(tx.items())))
if moving[k]:
tail += '<g id="ring-%s-turning" stroke="%s">%s</g>' % (k, th[k], ''.join(moving[k]))
frag += tail # all moving parts last: the still line work stays one layer
area = float((lbl != EMPTY).sum())
cov = (sum(math.pi * e['r'] ** 2 for e in circles) + sum((2 * e['r'] + 1) ** 2 for e in tiles)) / max(area, 1)
return frag, {'circles': len(circles), 'tiles': len(tiles), 'tile_hatch_runs': runs, 'area_covered_by_pieces': round(cov, 3),
'hatch_runs': runs, 'outlines': len(els), 'turning_rings': n_turn, 'tile_options': asdict(t)}
# --------------------------------------------------------------------------- pipeline
def process_image(path, o: lh.Options, t: TileOptions = None, grounds=('white', 'black'), progress=None, params=None) -> dict:
"""-> {ground: svg string}"""
o = o.checked(); t = t or TileOptions(); geo = lh.geometry(o); u = geo['u']
say = progress or (lambda msg: None)
sheet, im = lh._plan(lh.load_image(path), o)
aspect = im.size[0] / im.size[1]
say('signing'); params = params or lh.sign(Path(path).read_bytes(), o)
say('clustering'); an = lh.analyse(im, o); an['palette'] = lh.palette_of(params)
out = {}
for g in grounds:
lay0 = lh.LingJS.layout(lh._layout_opts(o, sheet, aspect, [1 / 7] * 7, params['sha3_256']))
pxu = lay0['K'] * u; box = lay0['picture']
mw, mh = int(box['w'] * pxu // geo['sc']), int(box['h'] * pxu // geo['sc'])
say('posterizing on ' + g); lbl = lh.posterize(an, o, g, geo, (mw, mh))
shares = (np.bincount(lbl.ravel(), minlength=256)[:28].reshape(7, 4).sum(1) / max(1, int((lbl != EMPTY).sum()))).tolist()
frag, stats = picture(lbl, params, g, geo, t, lambda m, g=g: say('%s on %s' % (m, g)))
lay = lh.LingJS.layout(lh._layout_opts(o, sheet, aspect, shares, params['sha3_256']))
pw, ph = mw * geo['sc'], mh * geo['sc']
px = round(box['x'] * pxu + (box['w'] * pxu - pw) / 2); py = round(box['y'] * pxu + (box['h'] * pxu - ph) / 2)
stats['hue_share'] = dict(zip(KEYS, [round(s, 4) for s in shares]))
out[g] = lh.compose(o, g, geo, sheet, lay, frag, (px, py), (pw, ph), params, stats)
if t.animate: # a screen piece: fills the window, on its own ground
out[g] = out[g].replace('<g id="picture"', animation_css(g, geo) + '<g id="picture"', 1)
out[g] = re.sub(r'<svg xmlns="http://www.w3.org/2000/svg" width="[\d.]+mm" height="[\d.]+mm"',
'<svg xmlns="http://www.w3.org/2000/svg" width="100%%" height="100%%" style="background:%s"' % THEMES[g]['ground'],
out[g], count=1)
return out
def write_png(svg: str, path, theme: str, scale: float = 2.0):
"""The sheet as a PNG at `scale` x screen size (96 px per inch): A4 at 2x is 1588 x 2246 px."""
m = re.search(r'width="([\d.]+)mm" height="([\d.]+)mm"', svg)
if m: wmm, hmm = (float(v) for v in m.groups())
else: # animated sheets fill the window: take the size from the viewBox
vb = [float(v) for v in re.search(r'viewBox="([^"]+)"', svg).group(1).split()]; wmm, hmm = vb[2] / 1440 * 25.4, vb[3] / 1440 * 25.4
wpx, hpx = round(round(wmm / 25.4 * 96) * scale), round(round(hmm / 25.4 * 96) * scale) # A4 = 794 x 1123 at 1x
try:
import cairosvg
cairosvg.svg2png(bytestring=svg.encode('utf-8'), write_to=str(path), output_width=wpx, output_height=hpx,
background_color=THEMES[theme]['ground'])
except ImportError: # plainer rendering without cairosvg
cv2.imwrite(str(path), cv2.cvtColor(lh.render_preview(svg, wpx, THEMES[theme]['ground']), cv2.COLOR_RGB2BGR))
def run_batch(paths, o: lh.Options, t: TileOptions, out_dir, grounds=('white', 'black'), previews=True, progress=None,
png_scale: float = 0.0) -> list[dict]:
"""Writes <name>_tiles_white.svg / <name>_tiles_black.svg (+ previews) and all.zip."""
out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True); records = []
for n, p in enumerate(paths):
p = Path(p); stem = re.sub(r'[^\w.-]+', '_', p.stem) or 'image'
rec = {'source': str(p), 'svg': {}, 'preview': {}, 'png': {}, 'error': None}
try:
say = (lambda msg, n=n, p=p: progress(n, len(paths), '%s: %s' % (p.name, msg))) if progress else None
for g, svg in process_image(p, o, t, grounds, say).items():
f = out_dir / ('%s_tiles_%s.svg' % (stem, g)); f.write_text(svg, encoding='utf-8'); rec['svg'][g] = str(f)
if png_scale > 0:
pf = out_dir / ('%s_tiles_%s.png' % (stem, g)); write_png(svg, pf, g, png_scale); rec['png'][g] = str(pf)
if previews:
pf = out_dir / ('%s_tiles_%s_preview.png' % (stem, g))
cv2.imwrite(str(pf), cv2.cvtColor(lh.render_preview(svg, ground=THEMES[g]['ground']), cv2.COLOR_RGB2BGR)); rec['preview'][g] = str(pf)
except Exception as e:
rec['error'] = '%s: %s' % (type(e).__name__, e)
records.append(rec)
svgs = [f for r in records for f in r['svg'].values()]
if svgs:
with zipfile.ZipFile(out_dir / 'all.zip', 'w', zipfile.ZIP_DEFLATED) as z:
for f in svgs: z.write(f, Path(f).name)
return records
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
ap.add_argument('images', nargs='+'); ap.add_argument('--out', default='ling_tiles_out')
ap.add_argument('--grounds', nargs='+', default=['white', 'black'], choices=['white', 'black'])
ap.add_argument('--no-previews', action='store_true')
ap.add_argument('--png-scale', type=float, default=0.0, help='also write PNGs at this multiple of screen size (2 = 1588 x 2246 for A4)')
for cls in (lh.Options, TileOptions):
for f in fields(cls):
kind = {'int': int, 'float': float, 'bool': lambda v: v.lower() in ('1', 'true', 'yes')}.get(f.type, str)
ap.add_argument('--' + f.name.replace('_', '-'), type=kind, default=f.default)
a = ap.parse_args()
o = lh.Options(**{f.name: getattr(a, f.name) for f in fields(lh.Options)})
t = TileOptions(**{f.name: getattr(a, f.name) for f in fields(TileOptions)})
for r in run_batch(a.images, o, t, a.out, a.grounds, not a.no_previews, lambda i, n, m: print('[%d/%d] %s' % (i + 1, n, m), flush=True),
png_scale=a.png_scale):
print(r['source'], '->', r['error'] or ', '.join(r['svg'].values()))
if __name__ == '__main__':
main()