#!/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'.*?', '', 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('' % (th[k], d) for k, fi, d in dirs if fi != 1) anim.append('%s' % ''.join('' % (th[k], d) for k, fi, d in dirs if fi == 1)) for k in KEYS: if out_d[k]: extra.append('%s%s' % ( k, th[k], ''.join(out_d[k]), '' % (''.join(br_d[k]), round(geo['line'] * 1.4)) if br_d[k] else '', ('' % ''.join(let_d[k]) if let_d[k] else '') + ('' % (''.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('%s' % (lit_color(th[k], theme), ''.join( '' % ((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('%s' % ( th[k], cx, cy, _rnd(cx, cy, 7) * 9.0, ld, '' % (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 '' % (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 = ('' % ''.join(r['lines']) if r['lines'] else '') + ''.join( '' % (''.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 += '%s' % (lit, ''.join('' % ( (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('' % ( 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 '%s' % (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 \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 += '%s' % ( k, th[k], ''.join(ln), ''.join('' % (''.join(ds), wdt) for wdt, ds in sorted(tx.items()))) if moving[k]: tail += '%s' % (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(' list[dict]: """Writes _tiles_white.svg / _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()