Spaces:
Paused
Paused
File size: 44,355 Bytes
68972cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 | #!/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('&', '&').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()
|