#!/usr/bin/env python3 """Ling hatch: photos -> posterized, colour-coded, signature-driven crosshatch sheets. For each image this writes an A4 SVG on white and/or on black. Nothing is filled: every region is an outline plus generated hatch, in the seven Ling hues. The hatch families (torus windings, Mobius zigzag, tetrahedral lattice) take their angles and periods from an ML-DSA-87 signature of the image file. That is a visual encoding of the signature; it does not protect or authenticate the print. Use as a library (process_image / run_batch), from the command line (python ling_hatch.py photo.jpg --out out), or through app.py (Gradio). """ from __future__ import annotations import argparse, hashlib, json, math, os, re, subprocess, tempfile, threading, zipfile import xml.etree.ElementTree as ET from dataclasses import dataclass, asdict, fields from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageOps from scipy.ndimage import distance_transform_edt from scipy.optimize import linear_sum_assignment from skimage import color as skc from sklearn.cluster import KMeans HERE = Path(__file__).resolve().parent KEYS = ['asm', 'lit', 'bind', 'ctl', 'call', 'fn', 'mod'] # ROYGBIV THEMES = { # print: exact hues, no ground. screen: hues lifted 22% toward white, black ground. 'white': {'ground': '#ffffff', 'ink': '#56657a', 'asm': '#e5383b', 'lit': '#f77f00', 'bind': '#d19b00', 'ctl': '#2a9d49', 'call': '#1f6fd6', 'fn': '#4b3fc4', 'mod': '#9b3fc9'}, 'black': {'ground': '#000000', 'ink': '#7b8797', 'asm': '#eb6466', 'lit': '#f99b38', 'bind': '#dbb138', 'ctl': '#59b371', 'call': '#508fdf', 'fn': '#7369d1', 'mod': '#b169d5'}, } FAMILIES = ['torus', 'mobius', 'tetra'] WINDINGS = [(1, 2), (2, 3), (1, 3), (3, 4), (2, 5), (3, 5), (1, 4), (3, 7), (2, 1), (3, 2), (3, 1), (4, 3), (5, 2), (5, 3), (4, 1), (7, 3)] STEP_MULTS = {4: [8, 4, 2, 1], 3: [8, 2, 1], 2: [4, 1]} # every Nth line, sparsest first QUALITY_MM = {'draft': 0.106, 'standard': 0.053, 'fine': 0.035} # size of one working-mask pixel CTX = b'ling/hatch/v1' PERCEPTUAL_CLUSTERS = 18 # colour clusters used by the perceptual coding NEUTRAL_CHROMA = 3.5 # CIELAB chroma below which a colour counts as neutral EMPTY = 255 # label of transparent pixels: left as bare ground @dataclass class Options: # sheet layout: str = 'instruments' # instruments: picture + legend, dial, digest, title plate | picture: picture only crop: str = 'fill' # fill: crop the image to the picture area | fit: keep the whole image anchor: str = 'center' # which part survives a crop: center, top, bottom, left, right orientation: str = 'auto' # auto, portrait, landscape (landscape applies to the picture layout) margin_mm: float = 10.0 # posterize hues: int = 7 # colour regions, 3..7 steps: int = 4 # hatch densities per hue, 2..4 coding: str = 'appearance' # perceptual | cryptographic | appearance | spectrum | spectrum-reversed analysis_px: int = 1024 # long side used for analysis; more = finer regions simplify: float = 1.0 # edge-preserving flattening before clustering, 0..3 smooth: float = 1.0 # boundary smoothing, 0.5..4 min_region: int = 30 # smallest region kept, in analysis pixels contrast: float = 2.0 # local contrast (CLAHE clip limit), 0 = off gamma: float = 1.25 # >1 darkens mid-tones tone_weight: float = 0.8 # how much lightness (vs colour) decides a region vivid: float = -1.0 # 0: density follows tone only .. 1: saturated colours get dense hatch; -1: auto keep_transparent: bool = True # transparent parts of the image stay empty: no hatch, no outline seed: int = 87 # hatch and print dpi: int = 1440 line_mm: float = 0.123 spacing_mm: float = 0.229 # densest spacing (the design system floor is 0.22 mm) quality: str = 'standard' # draft | standard | fine black_ground: bool = True # paint the black ground into the black SVG # title plate and key title: str = 'LING' subtitle: str = 'fips 204' rev: str = 'rev 2026.1' key_phrase: str = '' # empty: fresh random key per image; set: reproducible output def checked(self) -> 'Options': o = Options(**asdict(self)) o.hues = int(min(7, max(3, o.hues))); o.steps = int(min(4, max(2, o.steps))) o.analysis_px = int(min(3000, max(384, o.analysis_px))) o.spacing_mm = max(float(o.spacing_mm), 1.6 * float(o.line_mm)) o.margin_mm = float(min(30, max(1, o.margin_mm))) if o.quality not in QUALITY_MM: o.quality = 'standard' if o.layout not in ('instruments', 'picture'): o.layout = 'instruments' return o # --------------------------------------------------------------------------- colour def _hex2rgb(h): return np.array([int(h[i:i + 2], 16) for i in (1, 3, 5)], float) / 255 def _lin(c): return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def _srgb(c): return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.clip(c, 0, 1) ** (1 / 2.4) - 0.055) def geometry(o: Options) -> dict: """Pixel geometry of lines and hatch at the chosen dpi.""" u = o.dpi / 25.4 line = max(1, round(o.line_mm * u)) base2 = max(o.spacing_mm * u, line * 1.6) mults = STEP_MULTS[o.steps] cover = [1 - (1 - line / (base2 * m)) ** 2 for m in mults] base3 = line / (1 - (1 - cover[-1]) ** (1 / 3)) # same coverage with three directions sc = max(1, round(QUALITY_MM[o.quality] * u)) return {'u': u, 'line': line, 'base2': base2, 'base3': base3, 'mults': mults, 'cover': cover, 'sc': sc} def appearance(theme: str, cover) -> tuple[np.ndarray, np.ndarray]: """sRGB and Lab of each hue hatched at each coverage on the ground.""" t = THEMES[theme]; g = _lin(_hex2rgb(t['ground'])) rgb = np.array([[_srgb(c * _lin(_hex2rgb(t[k])) + (1 - c) * g) for c in cover] for k in KEYS]) return rgb, skc.rgb2lab(rgb.reshape(1, -1, 3)).reshape(7, len(cover), 3) # --------------------------------------------------------------------------- image def load_image(path) -> Image.Image: im = Image.open(path) im = ImageOps.exif_transpose(im) if im.mode in ('RGBA', 'LA', 'PA') or (im.mode == 'P' and 'transparency' in im.info): im = im.convert('RGBA') if np.asarray(im.getchannel('A')).min() < 128: return im # keeps its transparency; see analyse() bg = Image.new('RGBA', im.size, (255, 255, 255, 255)); bg.alpha_composite(im); im = bg return im.convert('RGB') def crop_to(im: Image.Image, aspect: float, anchor: str) -> Image.Image: """Crop (never stretch) to width/height = aspect.""" w, h = im.size if w / h > aspect: nw = max(1, round(h * aspect)); x = {'left': 0, 'right': w - nw}.get(anchor, (w - nw) // 2) return im.crop((x, 0, x + nw, h)) nh = max(1, round(w / aspect)); y = {'top': 0, 'bottom': h - nh}.get(anchor, (h - nh) // 2) return im.crop((0, y, w, y + nh)) # --------------------------------------------------------------------------- posterize def analyse(im: Image.Image, o: Options) -> dict: """Ground-independent part: simplify, cluster into colour regions, measure tone.""" w, h = im.size; s = o.analysis_px / max(w, h) size = (max(8, round(w * s)), max(8, round(h * s))) mask = None # True where the image is opaque if im.mode == 'RGBA': if o.keep_transparent: mask = np.asarray(im.getchannel('A').resize(size, Image.LANCZOS)) >= 128 if mask.all() or not mask.any(): mask = None bg = Image.new('RGBA', im.size, (255, 255, 255, 255)); bg.alpha_composite(im); im = bg.convert('RGB') rgb = np.asarray(im.resize(size, Image.LANCZOS)) ok = np.ones(rgb.shape[:2], bool) if mask is None else mask sm = rgb if o.simplify > 0: sm = cv2.bilateralFilter(rgb, 7, 35 * o.simplify, 5) sm = cv2.medianBlur(sm, 3 if o.simplify < 2 else 5) lab = skc.rgb2lab(sm / 255.0) feat = lab.reshape(-1, 3) * np.array([o.tone_weight, 1.0, 1.0]) seen = feat[ok.reshape(-1)] stride = max(1, len(seen) // 150000) k = PERCEPTUAL_CLUSTERS if o.coding == 'perceptual' else o.hues km = KMeans(k, n_init=4, random_state=o.seed).fit(seen[::stride]) cl = km.predict(feat).reshape(lab.shape[:2]) cent = np.array([lab[(cl == i) & ok].mean(0) if ((cl == i) & ok).any() else km.cluster_centers_[i] / [o.tone_weight, 1, 1] for i in range(k)]) pop = np.array([((cl == i) & ok).sum() / ok.sum() for i in range(k)]) L = lab[..., 0].astype(np.float32) if o.contrast > 0: L8 = np.clip(L * 2.55, 0, 255).astype(np.uint8) Lc = cv2.createCLAHE(clipLimit=float(o.contrast), tileGridSize=(4, 4)).apply(L8).astype(np.float32) / 2.55 L = 0.5 * L + 0.5 * Lc L = cv2.GaussianBlur(L, (0, 0), 0.8) lo, hi = np.percentile(L[ok], [1, 99.5]) Ln = np.clip((L - lo) / max(hi - lo, 1e-3), 0, 1) ** o.gamma chroma = float(np.hypot(lab[..., 1], lab[..., 2])[ok].mean()) return {'cl': cl, 'cent': cent, 'pop': pop, 'Ln': Ln, 'ab': lab[..., 1:].astype(np.float32), 'mask': mask, 'auto_vivid': 0.5 * float(np.clip((chroma - 25) / 25, 0, 1))} def _mode_clean(lbl, sigma): best = np.zeros(lbl.shape, np.float32); out = lbl.copy() for v in np.unique(lbl): sc = cv2.GaussianBlur((lbl == v).astype(np.float32), (0, 0), sigma) m = sc > best; out[m] = v; best[m] = sc[m] return out def _drop_small(lbl, min_area): if min_area <= 1: return lbl kill = np.zeros(lbl.shape, bool) for v in np.unique(lbl): n, cc, st, _ = cv2.connectedComponentsWithStats((lbl == v).astype(np.uint8), connectivity=4) small = np.nonzero(st[:, cv2.CC_STAT_AREA] < min_area)[0]; small = small[small != 0] if len(small): kill |= np.isin(cc, small) if kill.any() and not kill.all(): idx = distance_transform_edt(kill, return_distances=False, return_indices=True) return lbl[idx[0], idx[1]] return lbl def posterize(an: dict, o: Options, theme: str, geo: dict, mask_wh=None) -> np.ndarray: """Label map, label = hue*4 + step. With mask_wh, upscaled smoothly to the working mask.""" _, blab = appearance(theme, geo['cover']) Lmin, Lmax = blab[..., 0].min(), blab[..., 0].max() cent, pop = an['cent'], an['pop']; k = len(cent) T = Lmin + an['Ln'] * (Lmax - Lmin) if o.coding == 'perceptual': # Many small colour clusters, each given its own best hue; a hue may serve several clusters, so no colour is # pushed onto a far hue just to use all seven. A cluster with any colour to it takes the palette hue at the # nearest hue angle (CIELAB), so skin stays warm and hair stays violet whatever the ground. A neutral # cluster has no hue to keep and takes the nearest hatched appearance instead. ab_c = np.array([an['ab'][an['cl'] == c].mean(0) if (an['cl'] == c).any() else cent[c, 1:] for c in range(k)]) T_c = np.array([T[an['cl'] == c].mean() if (an['cl'] == c).any() else Lmin for c in range(k)]) flat = blab.reshape(-1, 3) d = (flat[None, :, 0] - T_c[:, None]) ** 2 + ((flat[None, :, 1:] - ab_c[:, None, :]) ** 2).sum(-1) hue_of = d.argmin(1) // blab.shape[1] pal = np.arctan2(blab[:, -1, 2], blab[:, -1, 1]) # hue angle of each hue at its densest for c in range(k): if np.hypot(*ab_c[c]) >= NEUTRAL_CHROMA: hue_of[c] = int(np.abs(np.angle(np.exp(1j * (pal - np.arctan2(ab_c[c, 1], ab_c[c, 0]))))).argmin()) if o.coding == 'cryptographic': # colour is a code, not a likeness: regions ordered light to dark take the hues in the order the # signature gives. Tone alone sets the density. perm = an.get('palette') or list(range(7)) hue_of = np.zeros(k, int); hue_of[np.argsort(-cent[:, 0])] = [perm[i % 7] for i in range(k)] elif o.coding == 'perceptual': pass elif o.coding.startswith('spectrum'): order = np.argsort(-cent[:, 0]) # lightest first slots = np.round(np.linspace(0, 6, k)).astype(int) if o.coding.endswith('reversed'): slots = slots[::-1] hue_of = np.zeros(k, int); hue_of[order] = slots else: # the hue whose hatched appearance on this ground is nearest the cluster, each hue once cost = np.array([[np.linalg.norm(blab[h] - cent[c], axis=1).min() * math.sqrt(max(pop[c], 1e-4)) for h in range(7)] for c in range(k)]) r, c = linear_sum_assignment(cost) hue_of = np.zeros(k, int); hue_of[r] = c if True: hue = hue_of[an['cl']] v = (0.0 if o.coding == 'cryptographic' else 0.5 if o.coding == 'perceptual' and o.vivid < 0 else an['auto_vivid'] if o.vivid < 0 else min(float(o.vivid), 1.5)) bl = blab[hue] # (h, w, steps, 3) d2 = (bl[..., 0] - T[..., None]) ** 2 if v > 0: # also match colourfulness, not only lightness d2 = d2 + v * v * ((bl[..., 1] - an['ab'][..., None, 0]) ** 2 + (bl[..., 2] - an['ab'][..., None, 1]) ** 2) step = d2.argmin(-1) lbl = (hue * 4 + step).astype(np.uint8) if an.get('mask') is not None: lbl[~an['mask']] = EMPTY lbl = _mode_clean(lbl, max(0.3, o.smooth)); lbl = _drop_small(lbl, o.min_region) lbl = _mode_clean(lbl, 0.8); lbl = _drop_small(lbl, o.min_region) if mask_wh is None: return lbl mw, mh = mask_wh best = np.full((mh, mw), -1, np.float32); out = np.zeros((mh, mw), np.uint8) for v in np.unique(lbl): sc = cv2.GaussianBlur((lbl == v).astype(np.float32), (0, 0), 1.1) up = cv2.resize(sc, (mw, mh), interpolation=cv2.INTER_CUBIC) m = up > best; out[m] = v; best[m] = up[m] return _drop_small(out, int(o.min_region * (mw / lbl.shape[1]) ** 2)).astype(np.uint8) def flat_preview(lbl: np.ndarray, theme: str, geo: dict) -> np.ndarray: """What the posterization will look like from a distance (RGB uint8).""" rgb, _ = appearance(theme, geo['cover']) pal = np.zeros((256, 3), np.uint8) pal[EMPTY] = np.clip(_hex2rgb(THEMES[theme]['ground']) * 255, 0, 255) for h in range(7): for s in range(len(geo['cover'])): pal[h * 4 + s] = np.clip(rgb[h, s] * 255, 0, 255) return pal[lbl] # --------------------------------------------------------------------------- signature def sign(msg: bytes, o: Options) -> dict: """ML-DSA-87 signature over the image bytes, and the hatch parameters it yields.""" pk = sig = None; how = '' if o.key_phrase: # reproducible: key derived from the phrase, deterministic signing from dilithium_py.ml_dsa import ML_DSA_87 pk, sk = ML_DSA_87.key_derive(hashlib.sha3_256(CTX + b'|key|' + o.key_phrase.encode()).digest()) sig = ML_DSA_87.sign(sk, msg, ctx=CTX, deterministic=True) assert ML_DSA_87.verify(pk, msg, sig, ctx=CTX) how = 'key derived from the key phrase, deterministic signing' else: try: from cryptography.hazmat.primitives.asymmetric import mldsa sk = mldsa.MLDSA87PrivateKey.generate() sig = sk.sign(msg, CTX); pk = sk.public_key().public_bytes_raw() sk.public_key().verify(sig, msg, CTX) except ImportError: from dilithium_py.ml_dsa import ML_DSA_87 pk, sk = ML_DSA_87.keygen(); sig = ML_DSA_87.sign(sk, msg, ctx=CTX) assert ML_DSA_87.verify(pk, msg, sig, ctx=CTX) del sk how = 'fresh random key, discarded after signing; hedged signing' def stream(tag, n=16): return hashlib.shake_256(CTX + b'|' + sig + b'|' + tag.encode()).digest(n) rot = stream('family')[0] % 3 hues = {} for i, k in enumerate(KEYS): b = stream(k); f = FAMILIES[(i + rot) % 3] p = {'family': f, 'phase': [b[6] / 256, b[7] / 256]} if f == 'torus': w = WINDINGS[b[0] % len(WINDINGS)]; sgn = -1 if b[1] & 1 else 1 p.update(winding=[w[0], sgn * w[1]], angle=math.degrees(math.atan2(sgn * w[1], w[0])), wavelength_mm=round(5 + (b[2] % 160) / 160 * 2.8, 3), amp_ratio=0.07 + (b[3] % 8) * 0.004) elif f == 'mobius': p.update(angle=b[0] * 180 / 256, half_angle=35 + b[1] % 21, band_mm=round(3.5 + (b[2] % 200) / 200 * 3.5, 3)) else: p.update(angle=b[0] * 60 / 256) hues[k] = p pb = stream('palette', 32); perm = list(range(7)) # Fisher-Yates over the seven hues for i in range(6, 0, -1): j = pb[i] % (i + 1); perm[i], perm[j] = perm[j], perm[i] return {'algorithm': 'ML-DSA-87 (FIPS 204)', 'key': how, 'context': CTX.decode(), 'palette': [KEYS[i] for i in perm], 'message': 'bytes of the image file (%d bytes)' % len(msg), 'sha3_256': hashlib.sha3_256(msg).hexdigest(), 'public_key': pk.hex(), 'signature': sig.hex(), 'derivation': 'per hue: SHAKE256(context | signature | hue key); family = (hue index + SHAKE256(context | signature | "family")[0] mod 3) mod 3', 'family_rotation': int(rot), 'hues': hues} # --------------------------------------------------------------------------- hatch families def _tri(x, period): t = (x / period) % 1.0 return 4 * np.abs(t - 0.5) - 1 def families(p: dict, geo: dict): """-> [(angle_deg, line offset px, wave(x), phase, straight)] torus : (p,q) windings of a flat torus drawn with the tube's sinusoidal projection. mobius: straight lines on an unrolled Mobius strip; slope flips at every band edge. tetra : geodesics of an unfolded tetrahedron, the three directions of the triangular lattice. """ u = geo['u'] if p['family'] == 'torus': lam = p['wavelength_mm'] * u; amp = lam * p['amp_ratio'] off = geo['base2'] / math.cos(math.atan(2 * math.pi * p['amp_ratio'])) w = lambda x: amp * np.sin(2 * np.pi * x / lam) return [(p['angle'], off, w, p['phase'][0], False), (p['angle'] + 90, off, w, p['phase'][1], False)] if p['family'] == 'mobius': B = p['band_mm'] * u; amp = B * math.tan(math.radians(p['half_angle'])) / 2 off = geo['base2'] / math.cos(math.radians(p['half_angle'])) return [(p['angle'], off, lambda x: amp * _tri(x, 2 * B), p['phase'][0], False), (p['angle'], off, lambda x: -amp * _tri(x, 2 * B), p['phase'][0], False)] z = lambda x: np.zeros_like(x) return [(p['angle'] + a, geo['base3'], z, 0.0, True) for a in (0, 60, 120)] def _lines(fm, D, ds): th_deg, off, wave, phase, _ = fm th = math.radians(th_deg) xs = (np.arange(math.floor(-D / ds), math.ceil(D / ds) + 1) * ds).astype(np.float64) wv = wave(xs); amp = float(np.abs(wv).max()) if len(wv) else 0.0 j = np.arange(math.floor((-D - amp) / off - phase), math.ceil((D + amp) / off - phase) + 1) yy = (j[:, None] + phase) * off + wv[None, :] xx = np.broadcast_to(xs[None, :], yy.shape) X = xx * math.cos(th) - yy * math.sin(th); Y = xx * math.sin(th) + yy * math.cos(th) return X.astype(np.float32), Y.astype(np.float32), j def _clip(X, Y, j, mult, inside, straight, min_samples=3): rows = np.nonzero(j % mult == 0)[0] if not len(rows): return [] Xr, Yr = X[rows], Y[rows] ins = inside(Xr, Yr) pad = np.zeros((ins.shape[0], ins.shape[1] + 2), np.int8); pad[:, 1:-1] = ins d = np.diff(pad, axis=1) r0, s0 = np.nonzero(d == 1); _, s1 = np.nonzero(d == -1) keep = (s1 - s0) >= min_samples r0, s0, s1 = r0[keep], s0[keep], s1[keep] - 1 if straight: a = np.stack([Xr[r0, s0], Yr[r0, s0], Xr[r0, s1], Yr[r0, s1]], 1) return list(np.rint(a).astype(np.int32).reshape(-1, 2, 2)) out = [] for r, a, b in zip(r0, s0, s1): pts = np.stack([Xr[r, a:b + 1], Yr[r, a:b + 1]], 1) out.append(np.rint(cv2.approxPolyDP(pts.reshape(-1, 1, 2), 0.7, False).reshape(-1, 2)).astype(np.int32)) return out def _d(polys, closed=False): z = 'z' if closed else '' return ''.join('M%d %d' % (p[0][0], p[0][1]) + ''.join('l%d %d' % (a, b) for a, b in np.diff(np.asarray(p), axis=0)) + z for p in polys) def hatch_picture(lbl: np.ndarray, params: dict, theme: str, geo: dict, by_direction=None) -> tuple[str, dict]: """Outlines and clipped hatch for every region. Picture-local device px.""" sc = geo['sc']; mh, mw = lbl.shape; cx, cy = mw * sc / 2, mh * sc / 2 D = 0.5 * math.hypot(mw * sc, mh * sc) + 4 * sc nsteps = len(geo['mults']) def interior(r): k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1)) return cv2.dilate(lbl, k) == cv2.erode(lbl, k) insets = [] for s, m in enumerate(geo['mults']): sp = geo['base2'] * m insets.append(max(1, round(min(max(sp / 2, geo['line']), 2 * geo['line']) / sc))) inner = {r: interior(r) for r in set(insets)} def inside(code, r): def f(X, Y): mx = np.floor((X + cx) / sc).astype(np.int32); my = np.floor((Y + cy) / sc).astype(np.int32) ok = (mx >= 0) & (my >= 0) & (mx < mw) & (my < mh) mx = np.clip(mx, 0, mw - 1); my = np.clip(my, 0, mh - 1) return ok & (lbl[my, mx] == code) & inner[r][my, mx] return f frag = []; n_runs = n_out = 0 cross = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) off = np.array([round(cx), round(cy)], np.int32) for h, key in enumerate(KEYS): present = [s for s in range(nsteps) if (lbl == h * 4 + s).any()] if not present: continue per = {s: [] for s in present} for fi, fm in enumerate(families(params['hues'][key], geo)): X, Y, j = _lines(fm, D, sc) for s in present: runs = [q + off for q in _clip(X, Y, j, geo['mults'][s], inside(h * 4 + s, insets[s]), fm[4])] per[s] += runs if by_direction is not None and runs: # (hue key, direction index, path data), for callers that animate by_direction.append((key, fi, _d(runs))) hp = ''.join('' % (s + 1, _d(per[s])) for s in present if per[s]) n_runs += sum(len(per[s]) for s in present) op = '' for s in present: m = cv2.erode((lbl == h * 4 + s).astype(np.uint8), cross) cs, _ = cv2.findContours(m, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) polys = [] for c in cs: if len(c) < 8: continue ap = cv2.approxPolyDP(c, 0.6, True).reshape(-1, 2) if len(ap) >= 3: polys.append((ap * sc + sc // 2).astype(np.int32)) if polys: op += '' % (s + 1, _d(polys, True)); n_out += len(polys) frag.append('%s%s' % (key, THEMES[theme][key], params['hues'][key]['family'], key, hp, key, op)) return ''.join(frag), {'hatch_runs': n_runs, 'outlines': n_out} # --------------------------------------------------------------------------- Ling components LAYOUT_JS = r""" function lingLayoutJSON(json) { var o = JSON.parse(json), L = Ling, S = 32, GAP = 8, M = GAP * 2 + 6, GUT = GAP * 2; var K = o.tile_mm / S, sheetW = o.sheet_w_mm / K, sheetH = o.sheet_h_mm / K, mar = o.margin_mm / K; var out = '', res = { K: K, dropped: '' }; function clean(t) { var s = ''; t = String(t == null ? '' : t); for (var i = 0; i < t.length; i++) { var ch = t[i]; if (ch === ' ' || L.geometry.has('daemon', ch)) s += ch; else if (L.geometry.has('daemon', ch.toUpperCase())) s += ch.toUpperCase(); else if (L.geometry.has('daemon', ch.toLowerCase())) s += ch.toLowerCase(); else res.dropped += ch; } return s.replace(/^\s+|\s+$/g, ''); } function fitBox(aw, ah) { if (!o.fit) return { w: aw, h: ah }; return o.aspect > aw / ah ? { w: aw, h: aw / o.aspect } : { w: ah * o.aspect, h: ah }; } if (o.layout !== 'instruments') { var b = fitBox(sheetW - 2 * mar - 2 * M, sheetH - 2 * mar - 2 * M); var fx = (sheetW - b.w) / 2 - M, fy = (sheetH - b.h) / 2 - M; out += L.Frame({ x: fx, y: fy, w: b.w + 2 * M, h: b.h + 2 * M, ticks: true }); res.picture = { x: fx + M, y: fy + M, w: b.w, h: b.h }; res.markup = out; res.swatches = []; res.hub = null; return JSON.stringify(res); } var W = sheetW - 2 * mar, x0 = mar; var gridW = L.wordWidth('0000000000000000', S), gridH = 4 * S + 3 * S * 0.14; var rowA = 4 * S * 1.7 - S * 0.7 + 2 * M, rowB = gridH + 2 * M; var maxInner = W - GUT - (gridW + 2 * M) - 40; function trim(t) { t = clean(t); while (t.length && L.wordWidth(t, S) > maxInner + 0.5) t = t.slice(0, -1); return t; } var title = clean(o.title) || 'LING', sub = trim(o.subtitle), rev = trim(o.rev); var ts = rowB - 40 - (sub ? S + 8 : 0) - (rev ? S + 14 : 0); if (L.wordWidth(title, ts) > maxInner) ts = maxInner / (title.length * 1.14 - 0.14); var plateOpts = { title: title, subtitle: sub, rev: rev, size: ts, small: S }; var plate = L.TitlePlate.size(plateOpts); var band = rowA + GUT + rowB, zoneH = sheetH - 2 * mar - band - GUT; var pb = fitBox(W - 2 * M, zoneH - 2 * M); var pfx = (sheetW - pb.w) / 2 - M, pfy = mar + (zoneH - pb.h) / 2 - M; out += L.Frame({ x: pfx, y: pfy, w: pb.w + 2 * M, h: pb.h + 2 * M, ticks: true }); res.picture = { x: pfx + M, y: pfy + M, w: pb.w, h: pb.h }; // legend with each hue's hatch at every density, and the dial of hue shares var yA = sheetH - mar - band, dialW = rowA, legW = W - GUT - dialW; out += L.Frame({ x: x0, y: yA, w: legW, h: rowA }); var ix = x0 + M, iy = yA + M, colGap = 28, cw = (legW - 2 * M - colGap) / 2; var labelW = S * 2.2 + L.wordWidth('0000', S), n = o.steps, swGap = 4, sw = (cw - labelW - 12 - (n - 1) * swGap) / n; var cols = [['asm', 'lit', 'bind', 'ctl'], ['call', 'fn', 'mod']]; res.swatches = []; for (var c = 0; c < 2; c++) { var cx = ix + c * (cw + colGap); out += L.Legend({ x: cx, y: iy, size: S, keys: cols[c] }); for (var r = 0; r < cols[c].length; r++) for (var s = 0; s < n; s++) res.swatches.push({ key: cols[c][r], step: s, x: cx + labelW + 12 + s * (sw + swGap), y: iy + r * S * 1.7, w: sw, h: S }); } var dcx = x0 + W - dialW / 2, dcy = yA + rowA / 2, R = 96, ud = R / 150, ringBand = 32, sectorBand = 22, items = []; for (var i = 0; i < L.keys.length; i++) if (o.shares[i] > 0.002) items.push({ color: L.keys[i], value: o.shares[i] }); out += L.Frame({ x: x0 + W - dialW, y: yA, w: dialW, h: rowA }); out += L.Dial({ cx: dcx, cy: dcy, r: R, diamonds: false, rings: [{ text: 'ml dsa 87 ', cells: 12, band: ringBand, color: 'ink' }], sectors: { band: sectorBand, items: items }, hub: { color: 'mod' } }); var rc = R - 9 * ud - 10 * ud - ringBand - 3 * ud - sectorBand; res.hub = { cx: dcx, cy: dcy, r: Math.max(rc - 8 * ud, R * 0.2) }; // SHA3-256 of the signed image as plain tiles, and the title plate var yB = yA + rowA + GUT, digW = W - GUT - plate.w, hex = String(o.digest).toUpperCase(); out += L.Frame({ x: x0, y: yB, w: digW, h: rowB }); for (var q = 0; q < 4; q++) out += L.Word({ text: hex.substr(q * 16, 16), x: x0 + (digW - gridW) / 2, y: yB + (rowB - gridH) / 2 + q * S * 1.14, size: S, color: 'bind' }); out += L.TitlePlate({ x: x0 + W - plate.w, y: yB + rowB - plate.h, title: title, subtitle: sub, rev: rev, size: ts, small: S }); res.markup = out; return JSON.stringify(res); } """ class LingJS: """Runs the real Ling component bundle (embedded V8 if available, else node).""" _lock = threading.Lock() @classmethod def layout(cls, opts: dict) -> dict: bundle = (HERE / 'ling_bundle.js').read_text(encoding='utf-8') payload = json.dumps(opts) with cls._lock: try: from py_mini_racer import MiniRacer except ImportError: MiniRacer = None if MiniRacer is not None: ctx = MiniRacer() # a short-lived context: closed before returning try: ctx.eval('var window = this, self = this;'); ctx.eval(bundle); ctx.eval(LAYOUT_JS) return json.loads(ctx.call('lingLayoutJSON', payload)) finally: ctx.close() with tempfile.TemporaryDirectory() as td: js = Path(td) / 'run.js' js.write_text('var window = globalThis, self = globalThis;\n' + bundle + '\nvar Ling = globalThis.Ling;\n' + LAYOUT_JS + '\nprocess.stdout.write(lingLayoutJSON(require("fs").readFileSync(0, "utf8")));', encoding='utf-8') try: r = subprocess.run(['node', str(js)], input=payload, capture_output=True, text=True, check=True) except FileNotFoundError as e: raise RuntimeError('Drawing the Ling frames and legend needs a JavaScript engine: ' 'pip install mini-racer, or install Node.js.') from e return json.loads(r.stdout) # --------------------------------------------------------------------------- sheet def sheet_size_mm(o: Options, image_aspect: float) -> tuple[float, float]: land = o.layout == 'picture' and (o.orientation == 'landscape' or (o.orientation == 'auto' and image_aspect > 1.05)) return (297.0, 210.0) if land else (210.0, 297.0) def _layout_opts(o, sheet, aspect, shares, digest): return {'layout': o.layout, 'tile_mm': 5.85, 'sheet_w_mm': sheet[0], 'sheet_h_mm': sheet[1], 'margin_mm': o.margin_mm, 'fit': o.crop == 'fit', 'aspect': aspect, 'steps': o.steps, 'shares': shares, 'digest': digest, 'title': o.title, 'subtitle': o.subtitle, 'rev': o.rev} def _esc(t): return str(t).replace('&', '&').replace('<', '<').replace('>', '>') def compose(o: Options, theme: str, geo: dict, sheet, lay: dict, pic_frag: str, pic_px, pic_wh, params: dict, stats: dict) -> str: t = THEMES[theme]; u = geo['u']; pxu = lay['K'] * u W, H = round(sheet[0] * u), round(sheet[1] * u) markup = re.sub(r'class="lg lg-(\w+)"', lambda m: 'stroke="%s"' % t.get(m.group(1), t['ink']), lay['markup']) extra = '' if lay.get('hub'): hub = lay['hub']; g = json.loads((HERE / 'hub_glyph.json').read_text(encoding='utf-8')) s = 1.18 * hub['r'] d = ''.join('M' + 'L'.join('%.2f %.2f' % (hub['cx'] + x * s, hub['cy'] + y * s) for x, y in c) + 'Z' for c in g['contours']) extra = '' % (t['mod'], d) sw = [] for s in lay.get('swatches', []): x, y, w, h = (round(s[k] * pxu) for k in ('x', 'y', 'w', 'h')) ins = geo['line'] + 1 inside = lambda X, Y, w=w, h=h: (np.abs(X) <= w / 2 - ins) & (np.abs(Y) <= h / 2 - ins) polys = [] for fm in families(params['hues'][s['key']], geo): X, Y, j = _lines(fm, max(w, h), geo['sc']) polys += _clip(X, Y, j, geo['mults'][s['step']], inside, fm[4], min_samples=2) polys = [q + np.array([x + w // 2, y + h // 2]) for q in polys] sw.append('' % (t[s['key']], x, y, w, h, -w, round(pxu), _d(polys), geo['line'])) meta = {k: params[k] for k in ('algorithm', 'key', 'context', 'message', 'sha3_256', 'public_key', 'signature', 'derivation', 'family_rotation', 'hues')} if o.coding == 'cryptographic' and params.get('palette'): meta['palette'] = {'order': params['palette'], 'rule': 'colour regions ordered light to dark take these hues in turn; ' 'order = Fisher-Yates shuffle of ROYGBIV driven by SHAKE256(context | signature | "palette")'} meta['sheet'] = {'size_mm': list(sheet), 'ground': theme, 'user_units': 'device pixels at %d dpi' % o.dpi, 'line_mm': round(geo['line'] / u, 4), 'densest_spacing_mm': round(geo['base2'] / u, 4), 'density_steps': 'every %s line' % ', '.join(str(m) for m in geo['mults']), 'options': asdict(o) | {'key_phrase': bool(o.key_phrase)}, **stats} meta_json = json.dumps(meta, indent=1).replace('&', '&').replace('<', '<') ground = ('\n' % (W, H, t['ground']) if theme == 'black' and o.black_ground else '') desc = ('A posterized picture drawn only in outline and hatch on a %s ground. Up to seven colours mark its tone and colour ' 'regions; each colour is hatched with one of three line families (torus windings, Mobius zigzag, tetrahedral ' 'lattice) whose angles and periods come from an ML-DSA-87 signature of the image file.' % theme) if o.layout == 'instruments': desc += (' Below: a legend of the seven colours with their hatch densities, a dial of each colour\'s share of the ' 'picture, the SHA3-256 digest of the signed image, and a title plate.') return ('\n' '\n' '%s: ML-DSA-87 hatch, %g x %g mm at %d dpi, on %s\n%s\n' '%s\n%s' '%s\n' '%s%s\n%s\n\n' % (sheet[0], sheet[1], W, H, _esc(o.title), sheet[0], sheet[1], o.dpi, theme, desc, meta_json, ground, pic_px[0], pic_px[1], geo['line'], pic_frag, pxu, markup, extra, ''.join(sw))) # --------------------------------------------------------------------------- preview raster _TOK = re.compile(r'[MLHVZAmlhvz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?') _FAST = re.compile(r'^[Mlz\d\s-]+$') def _arc(p0, r, large, sweep, p1): (x0, y0), (x1, y1) = p0, p1 dx, dy = (x0 - x1) / 2, (y0 - y1) / 2; d2 = dx * dx + dy * dy r = max(r, math.sqrt(d2)); k = math.sqrt(max(r * r - d2, 0) / d2) if d2 else 0 if large == sweep: k = -k cxp, cyp = k * dy, -k * dx; cx, cy = cxp + (x0 + x1) / 2, cyp + (y0 + y1) / 2 a0 = math.atan2(y0 - cy, x0 - cx); a1 = math.atan2(y1 - cy, x1 - cx) if sweep and a1 < a0: a1 += 2 * math.pi if not sweep and a1 > a0: a1 -= 2 * math.pi n = max(4, int(abs(a1 - a0) * 12)) return [(cx + r * math.cos(a), cy + r * math.sin(a)) for a in np.linspace(a0, a1, n + 1)[1:]] def _path_polys(d): """-> [(Nx2 float array, closed)] for the path commands this program and the Ling bundle emit.""" out = [] if _FAST.match(d): for sub in d.split('M')[1:]: closed = sub.endswith('z') nums = np.array(re.findall(r'-?\d+', sub), np.float64).reshape(-1, 2) out.append((np.cumsum(nums, axis=0), closed)) return out tok = _TOK.findall(d); i = 0; cur = (0.0, 0.0); pts = []; cmd = '' def num(): nonlocal i; v = float(tok[i]); i += 1; return v while i < len(tok): if tok[i].isalpha(): cmd = tok[i]; i += 1 if cmd in 'Zz': if pts: out.append((np.array(pts), True)); cur = pts[0] pts = []; continue if cmd == 'M': if pts: out.append((np.array(pts), False)) cur = (num(), num()); pts = [cur]; cmd = 'L'; continue if cmd == 'L': cur = (num(), num()) elif cmd == 'l': cur = (cur[0] + num(), cur[1] + num()) elif cmd == 'H': cur = (num(), cur[1]) elif cmd == 'h': cur = (cur[0] + num(), cur[1]) elif cmd == 'V': cur = (cur[0], num()) elif cmd == 'v': cur = (cur[0], cur[1] + num()) elif cmd == 'A': r = num(); num(); num(); large = num(); sweep = num(); p1 = (num(), num()) if not pts: pts = [cur] pts += _arc(cur, r, bool(large), bool(sweep), p1)[:-1]; cur = p1 else: i += 1; continue if not pts: pts = [cur] else: pts.append(cur) if pts: out.append((np.array(pts), False)) return out def render_preview(svg: str, width: int = 1240, ground: str = '#ffffff') -> np.ndarray: """Rasterize a sheet made by this program (RGB uint8). Draws at 4x and reduces.""" root = ET.fromstring(svg.encode('utf-8')) _, _, vw, vh = map(float, root.get('viewBox').split()) ss = 4; W = width * ss; k = W / vw; H = round(vh * k) img = np.empty((H, W, 3), np.uint8); img[:] = [int(ground[i:i + 2], 16) for i in (1, 3, 5)] ns = '{http://www.w3.org/2000/svg}' def walk(el, stroke, sw, sc, tx, ty): tag = el.tag.replace(ns, '') stroke = el.get('stroke', stroke); sw = float(el.get('stroke-width', sw)) tr = el.get('transform') if tr: m = re.match(r'translate\(([-\d.]+)[ ,]+([-\d.]+)\)', tr) if m: tx, ty = tx + sc * float(m.group(1)), ty + sc * float(m.group(2)) m = re.match(r'scale\(([-\d.]+)\)', tr) if m: sc = sc * float(m.group(1)) if tag == 'rect' and el.get('fill', 'none') != 'none': img[:] = [int(el.get('fill')[i:i + 2], 16) for i in (1, 3, 5)] elif tag == 'path' and stroke and stroke != 'none': col = tuple(int(stroke[i:i + 2], 16) for i in (1, 3, 5)) th = max(1, round(sw * sc * k)) for closed in (False, True): grp = [np.rint((p * sc + (tx, ty)) * k).astype(np.int32).reshape(-1, 1, 2) for p, c in _path_polys(el.get('d')) if c == closed and len(p) > 1] if grp: cv2.polylines(img, grp, closed, col, th, cv2.LINE_8) for ch in el: walk(ch, stroke, sw, sc, tx, ty) walk(root, None, 1.0, 1.0, 0.0, 0.0) return cv2.resize(img, (width, round(H / ss)), interpolation=cv2.INTER_AREA) # --------------------------------------------------------------------------- pipeline def palette_of(params: dict): """Hue indices of the signature's palette order (None for signatures made before palettes existed).""" return [KEYS.index(k) for k in params['palette']] if params.get('palette') else None def _plan(im: Image.Image, o: Options): """Sheet, layout box and the cropped image.""" aspect = im.size[0] / im.size[1] sheet = sheet_size_mm(o, aspect) lay = LingJS.layout(_layout_opts(o, sheet, aspect, [1 / 7] * 7, '0' * 64)) box = lay['picture'] if o.crop != 'fit': im = crop_to(im, box['w'] / box['h'], o.anchor) return sheet, im def preview_posterize(path, o: Options, grounds=('white', 'black'), params=None) -> dict: """Fast flat-colour previews of the posterization for tuning options.""" o = o.checked(); geo = geometry(o) sheet, im = _plan(load_image(path), o) an = analyse(im, o) if o.coding == 'cryptographic': # with no key phrase the key is fresh each run, so this palette is one example an['palette'] = palette_of(params or sign(Path(path).read_bytes(), o)) return {g: flat_preview(posterize(an, o, g, geo), g, geo) for g in grounds} def process_image(path, o: Options, grounds=('white', 'black'), progress=None, params=None) -> dict: """-> {ground: svg string}. One signature per image, shared by both grounds. Pass params (an earlier sign() result) to reuse a signature instead of signing again.""" o = o.checked(); geo = geometry(o); u = geo['u'] say = progress or (lambda msg: None) sheet, im = _plan(load_image(path), o) aspect = im.size[0] / im.size[1] say('signing'); params = params or sign(Path(path).read_bytes(), o) say('clustering'); an = analyse(im, o); an['palette'] = palette_of(params) out = {} for g in grounds: lay0 = LingJS.layout(_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 = 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() say('hatching on ' + g); frag, stats = hatch_picture(lbl, params, g, geo) lay = LingJS.layout(_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])) if lay.get('dropped'): stats['characters_without_a_daemon_glyph'] = lay['dropped'] out[g] = compose(o, g, geo, sheet, lay, frag, (px, py), (pw, ph), params, stats) return out def run_batch(paths, o: Options, out_dir, grounds=('white', 'black'), previews=True, progress=None) -> list[dict]: """Writes _white.svg / _black.svg (+ preview PNGs) and all.zip. Returns one record per image.""" out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True) records = []; used = set() for n, p in enumerate(paths): p = Path(p); stem = re.sub(r'[^\w.-]+', '_', p.stem) or 'image' while stem in used: stem += '_' used.add(stem) rec = {'source': str(p), 'svg': {}, 'preview': {}, '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, grounds, say).items(): f = out_dir / ('%s_%s.svg' % (stem, g)); f.write_text(svg, encoding='utf-8'); rec['svg'][g] = str(f) if previews: pf = out_dir / ('%s_%s_preview.png' % (stem, g)) cv2.imwrite(str(pf), cv2.cvtColor(render_preview(svg, ground=THEMES[g]['ground']), cv2.COLOR_RGB2BGR)); rec['preview'][g] = str(pf) except Exception as e: # keep the batch going; report per image 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_hatch_out') ap.add_argument('--grounds', nargs='+', default=['white', 'black'], choices=['white', 'black']) ap.add_argument('--no-previews', action='store_true') for f in fields(Options): if f.type == 'bool': ap.add_argument('--' + f.name.replace('_', '-'), type=lambda v: v.lower() in ('1', 'true', 'yes'), default=f.default) else: ap.add_argument('--' + f.name.replace('_', '-'), type={'int': int, 'float': float}.get(f.type, str), default=f.default) a = ap.parse_args() o = Options(**{f.name: getattr(a, f.name) for f in fields(Options)}) recs = run_batch(a.images, o, a.out, a.grounds, not a.no_previews, lambda i, n, m: print('[%d/%d] %s' % (i + 1, n, m), flush=True)) for r in recs: print(r['source'], '->', r['error'] or ', '.join(r['svg'].values())) if __name__ == '__main__': main()