Spaces:
Paused
Paused
Download ling_hatch.py from taellinglin/HatchLing: direct link, hf CLI and curl.
- Browser
- Download file 44.4 kB
-
https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/ling_hatch.py
- Command line
-
hf download hf://spaces/taellinglin/HatchLing/ling_hatch.py
-
curl -L -o ling_hatch.py https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/ling_hatch.py
44.4 kB
| #!/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 | |
| 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('<path data-density="%d" d="%s"/>' % (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 += '<path data-density="%d" d="%s"/>' % (s + 1, _d(polys, True)); n_out += len(polys) | |
| frag.append('<g id="%s" stroke="%s" data-family="%s"><g id="%s-hatch">%s</g><g id="%s-outline">%s</g></g>' | |
| % (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() | |
| 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 = '<g id="hub" stroke="%s"><path d="%s" stroke-width="0.8"/></g>' % (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('<g stroke="%s"><path d="M%d %dh%dv%dh%dz" stroke-width="%d"/><path d="%s" stroke-width="%d"/></g>' | |
| % (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 = ('<rect id="ground" width="%d" height="%d" fill="%s" stroke="none"/>\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 ('<?xml version="1.0" encoding="UTF-8"?>\n' | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="%gmm" height="%gmm" viewBox="0 0 %d %d" fill="none" ' | |
| 'stroke-linecap="butt" stroke-linejoin="miter" role="img" aria-labelledby="t d">\n' | |
| '<title id="t">%s: ML-DSA-87 hatch, %g x %g mm at %d dpi, on %s</title>\n<desc id="d">%s</desc>\n' | |
| '<metadata id="ml-dsa-87">%s</metadata>\n%s' | |
| '<g id="picture" transform="translate(%d %d)" stroke-width="%d" stroke-linejoin="round">%s</g>\n' | |
| '<g id="instruments" transform="scale(%.5f)">%s%s</g>\n<g id="swatches">%s</g>\n</svg>\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 <name>_white.svg / <name>_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() | |