HatchLing / ling_hatch.py
taellinglin's picture
Initial commit: Ling hatch with red/blue/green/black Gradio theme
68972cd
Raw History Blame Contribute Delete
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
@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('<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()
@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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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('&', '&amp;').replace('<', '&lt;')
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()