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('&', '&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()