Spaces:
Paused
Paused
Download ling_tiles.py from taellinglin/HatchLing: direct link, hf CLI and curl.
- Browser
- Download file 38.3 kB
-
https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/ling_tiles.py
- Command line
-
hf download hf://spaces/taellinglin/HatchLing/ling_tiles.py
-
curl -L -o ling_tiles.py https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/ling_tiles.py
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 | |
| 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() | |