File size: 14,748 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
#!/usr/bin/env python3
"""Gradio front end for ling_hatch: upload photos, tune the posterization, get A4 SVGs on white and black."""
import tempfile
from pathlib import Path

import gradio as gr
import numpy as np

import ling_hatch as lh
import ling_tiles as lt

GROUNDS = ['white', 'black']


def _options(layout, crop, anchor, orientation, margin, hues, steps, coding, analysis_px, simplify, smooth, min_region,

             contrast, gamma, tone_weight, vivid_auto, vivid, keep_transparent, seed, dpi, line_mm, spacing_mm, quality, black_ground, title,

             subtitle, rev, key_phrase):
    return lh.Options(
        layout='instruments' if layout.startswith('Picture with') else 'picture',
        crop='fill' if crop.startswith('Crop') else 'fit', anchor=anchor, orientation=orientation, margin_mm=margin,
        hues=int(hues), steps=int(steps), coding=coding, analysis_px=int(analysis_px), simplify=simplify, smooth=smooth,
        min_region=int(min_region), contrast=contrast, gamma=gamma, tone_weight=tone_weight,
        vivid=-1.0 if vivid_auto else vivid, keep_transparent=keep_transparent, seed=int(seed),
        dpi=int(dpi), line_mm=line_mm, spacing_mm=spacing_mm, quality=quality, black_ground=black_ground,
        title=title, subtitle=subtitle, rev=rev, key_phrase=key_phrase or '')


def _paths(files):
    return [f if isinstance(f, str) else f.name for f in (files or [])]


def quick_preview(files, grounds, *args):
    paths = _paths(files)
    if not paths:
        raise gr.Error('Add at least one image.')
    o = _options(*args); out = []
    for p in paths:
        try:
            for g, img in lh.preview_posterize(p, o, grounds or GROUNDS).items():
                out.append((img, '%s on %s' % (Path(p).name, g)))
        except Exception as e:
            gr.Warning('%s: %s' % (Path(p).name, e))
    return out


def generate(files, grounds, style, circle_bias, mix, tile_min, circle_max, ring_text, lettering, png2x, animate, *args, progress=gr.Progress()):
    paths = _paths(files)
    if not paths:
        raise gr.Error('Add at least one image.')
    grounds = grounds or GROUNDS
    o = _options(*args)
    out_dir = Path(tempfile.mkdtemp(prefix='ling_hatch_'))
    stages = ['signing', 'clustering'] + ['%s on %s' % (a, g) for g in grounds for a in ('posterizing', 'hatching')]

    def report(i, n, msg):
        stage = msg.rsplit(': ', 1)[-1]
        frac = (stages.index(stage) / len(stages)) if stage in stages else 0
        progress((i + frac) / n, desc=msg)

    if style.startswith('Tiles'):
        ling = lettering.startswith('Chesilin')
        t = lt.TileOptions(circle_bias=circle_bias, mix=mix, tile_min_mm=tile_min, circle_max_mm=circle_max, ring_text=ring_text or '',
                           ring_face='chesilin' if ling else 'lingring', tile_face='daemon' if ling else 'lingring', animate=bool(animate))
        recs = lt.run_batch(paths, o, t, out_dir, grounds, previews=True, progress=report, png_scale=2.0 if png2x else 0.0)
    else:
        recs = lh.run_batch(paths, o, out_dir, grounds, previews=True, progress=report)
    gallery, files_out, lines = [], [], []
    for r in recs:
        name = Path(r['source']).name
        if r['error']:
            lines.append('%s: failed (%s)' % (name, r['error'])); continue
        for g in grounds:
            gallery.append((r['preview'][g], '%s on %s' % (name, g))); files_out.append(r['svg'][g])
            if r.get('png', {}).get(g): files_out.append(r['png'][g])
        lines.append('%s: %s' % (name, ', '.join(Path(f).name for f in r['svg'].values())))
    if (out_dir / 'all.zip').exists():
        files_out.append(str(out_dir / 'all.zip'))
    return gallery, files_out, '\n'.join(lines)

# Red / blue / green on black, white text.
RED, BLUE, GREEN = '#e0242c', '#2b6fff', '#1fb85a'
THEME = gr.themes.Base(primary_hue=gr.themes.Color(*(['#ffe5e6', '#ffc2c4', '#ff9296', '#f6595f', RED, '#c11b22', '#9c151b', '#7a1015', '#580b0f', '#40080b', '#2a0507']),
                                                      name='ling_red'),
                       secondary_hue=gr.themes.Color(*(['#e6efff', '#c2d6ff', '#94b6ff', '#6393ff', BLUE, '#1f56d1', '#1944a6', '#14357f', '#0f2859', '#0a1b3d', '#060f22']),
                                                      name='ling_blue'),
                       neutral_hue=gr.themes.colors.neutral).set(
    body_background_fill='#000000', body_text_color='#ffffff', body_text_color_subdued='#e8e8e8',
    background_fill_primary='#0a0a0a', background_fill_secondary='#050505',
    block_background_fill='#0a0a0a', block_border_color=BLUE, block_border_width='1px',
    block_label_background_fill='#0a1b3d', block_label_text_color='#ffffff', block_title_text_color='#ffffff', block_info_text_color='#d8d8d8',
    border_color_primary=BLUE, border_color_accent=GREEN,
    input_background_fill='#000000', input_border_color=BLUE, input_border_color_focus=GREEN, input_placeholder_color='#b0b0b0',
    button_primary_background_fill=RED, button_primary_background_fill_hover='#ff3a42', button_primary_text_color='#ffffff', button_primary_border_color=RED,
    button_secondary_background_fill=BLUE, button_secondary_background_fill_hover='#4f88ff', button_secondary_text_color='#ffffff', button_secondary_border_color=BLUE,
    checkbox_background_color='#000000', checkbox_background_color_selected=GREEN, checkbox_border_color=BLUE, checkbox_border_color_selected=GREEN,
    checkbox_label_background_fill='#0a0a0a', checkbox_label_background_fill_selected='#0d3a1f', checkbox_label_text_color='#ffffff', checkbox_label_text_color_selected='#ffffff',
    slider_color=GREEN, link_text_color=GREEN, link_text_color_hover='#4ee389', link_text_color_active=GREEN,
    color_accent_soft='#0d3a1f', panel_background_fill='#050505', table_even_background_fill='#0a0a0a', table_odd_background_fill='#050505')
CSS = """

.gradio-container, .gradio-container * { --body-text-color: #fff; }

.gradio-container h1 { color: #fff; border-bottom: 3px solid %s; padding-bottom: .3em; }

.gradio-container .prose, .gradio-container .prose * , .gradio-container label span, .gradio-container .info { color: #fff !important; }

.gradio-container .label-wrap { border-left: 4px solid %s; }

.gradio-container button.primary { box-shadow: 0 0 0 2px %s inset; }

""" % (RED, GREEN, '#000')


with gr.Blocks(title='Ling hatch') as demo:
    gr.Markdown('# Ling hatch\nPhotos in, A4 outline-and-crosshatch sheets out: one SVG on white and one on black per image. '
                'Each sheet is posterized into the seven Ling hues and hatched with torus, Möbius and tetrahedral line '
                'families set by an ML-DSA-87 signature of the image file.')
    with gr.Row():
        with gr.Column(scale=1):
            files = gr.Files(label='Images', file_count='multiple', file_types=['image'], type='filepath')
            grounds = gr.CheckboxGroup(GROUNDS, value=GROUNDS, label='Grounds')
            style = gr.Radio(['Hatch', 'Tiles and text rings'], value='Hatch', label='Style',
                             info='Hatch: outlined regions filled with hatch. Tiles and text rings: regions packed with square tiles and circles of Ling Ring text.')
            layout = gr.Radio(['Picture with legend, dial, digest and title plate', 'Picture only'],
                              value='Picture with legend, dial, digest and title plate', label='Sheet')
            crop = gr.Radio(['Crop to fill the picture area', 'Fit the whole image'], value='Crop to fill the picture area',
                            label='Cropping', info='The image is never stretched.')
            with gr.Row():
                anchor = gr.Dropdown(['center', 'top', 'bottom', 'left', 'right'], value='center', label='Keep when cropping')
                orientation = gr.Dropdown(['auto', 'portrait', 'landscape'], value='auto', label='Orientation',
                                          info='Landscape applies to "Picture only".')
            with gr.Accordion('Posterize', open=True):
                with gr.Row():
                    hues = gr.Slider(3, 7, value=7, step=1, label='Colour regions', info='How many of the seven hues are used.')
                    steps = gr.Slider(2, 4, value=4, step=1, label='Hatch densities per hue')
                coding = gr.Dropdown(['perceptual', 'cryptographic', 'appearance', 'spectrum', 'spectrum-reversed'], value='perceptual',
                                     label='Colour coding',
                                     info='perceptual: each colour keeps its own hue. cryptographic: hues in the order the signature gives. '
                                          'appearance: seven regions, one hue each. spectrum: red to violet from light to dark.')
                with gr.Row():
                    analysis_px = gr.Slider(512, 2048, value=1024, step=128, label='Detail (analysis size, px)')
                    smooth = gr.Slider(0.5, 4, value=1.0, step=0.1, label='Edge smoothing')
                with gr.Row():
                    simplify = gr.Slider(0, 3, value=1.0, step=0.1, label='Flatten before clustering')
                    min_region = gr.Slider(0, 400, value=30, step=5, label='Smallest region (px)')
                with gr.Row():
                    contrast = gr.Slider(0, 5, value=2.0, step=0.1, label='Local contrast')
                    gamma = gr.Slider(0.5, 2.5, value=1.25, step=0.05, label='Gamma', info='Above 1 darkens mid-tones.')
                with gr.Row():
                    tone_weight = gr.Slider(0.2, 2, value=0.8, step=0.05, label='Tone vs colour',
                                            info='Higher: regions follow light and dark. Lower: they follow colour.')
                    seed = gr.Number(value=87, precision=0, label='Clustering seed')
                with gr.Row():
                    vivid_auto = gr.Checkbox(value=True, label='Colour strength: auto',
                                             info='Photos follow tone; flat, saturated artwork gets dense colour.')
                    vivid = gr.Slider(0, 1.5, value=0.0, step=0.05, label='Colour strength (when auto is off)',
                                      info='0: density follows light and dark only. 1: saturated colours get dense hatch.')
                keep_transparent = gr.Checkbox(value=True, label='Keep transparent areas empty',
                                               info='Transparent parts of a PNG get no hatch and no outline.')
            with gr.Accordion('Tiles and text rings', open=False):
                with gr.Row():
                    circle_bias = gr.Slider(0.5, 3, value=1.35, step=0.05, label='Circles vs tiles', info='Higher: more circles.')
                    mix = gr.Slider(0, 2, value=1.0, step=0.1, label='Patchiness', info='Higher: stronger patches of tiles and of circles.')
                with gr.Row():
                    tile_min = gr.Slider(0.4, 3, value=0.4, step=0.1, label='Smallest tile (mm)', info='Smaller: finer detail, more pieces.')
                    circle_max = gr.Slider(4, 30, value=15, step=1, label='Largest circle radius (mm)')
                ring_text = gr.Textbox(value='', label='Ring text', max_lines=1,
                                       info='Empty: each ring spells its colour key (asm, lit, bind, ctl, call, fn, mod).')
                lettering = gr.Radio(['Chesilin rings, Daemon tiles', 'Ling Ring'], value='Chesilin rings, Daemon tiles', label='Lettering')
                png2x = gr.Checkbox(value=False, label='Also write PNGs at 2x (1588 x 2246 for A4)')
                animate = gr.Checkbox(value=False, label='Animated SVG for screens',
                                      info='Rings turn, glyphs flash, letters flip, tile frames light in a wave, the crosshatch opens and closes. '
                                           'For print leave this off. Lighter settings run smoother: smallest tile 0.7 mm or more.')
            with gr.Accordion('Hatch and print', open=False):
                with gr.Row():
                    dpi = gr.Dropdown([720, 1440, 2880], value=1440, label='DPI grid')
                    quality = gr.Dropdown(['draft', 'standard', 'fine'], value='standard', label='Edge precision')
                with gr.Row():
                    line_mm = gr.Slider(0.08, 0.3, value=0.123, step=0.001, label='Line weight (mm)')
                    spacing_mm = gr.Slider(0.22, 0.8, value=0.229, step=0.001, label='Densest spacing (mm)')
                with gr.Row():
                    margin = gr.Slider(5, 25, value=10, step=0.5, label='Sheet margin (mm)')
                    black_ground = gr.Checkbox(value=True, label='Paint the black ground',
                                               info='Turn off to print the black version on black stock.')
            with gr.Accordion('Title plate and key', open=False):
                with gr.Row():
                    title = gr.Textbox(value='LING', label='Title', max_lines=1)
                    subtitle = gr.Textbox(value='fips 204', label='Subtitle (10 characters)', max_lines=1)
                    rev = gr.Textbox(value='rev 2026.1', label='Revision (10 characters)', max_lines=1)
                key_phrase = gr.Textbox(value='', label='Key phrase', max_lines=1, type='password',
                                        info='Empty: a fresh ML-DSA-87 key per image, discarded after signing. '
                                             'Set: the key comes from the phrase and the same image gives the same hatch.')
            with gr.Row():
                btn_prev = gr.Button('Preview posterization')
                btn_run = gr.Button('Generate A4 sheets', variant='primary')
        with gr.Column(scale=1):
            gallery = gr.Gallery(label='Previews', columns=2, height=720, object_fit='contain')
            downloads = gr.Files(label='SVG files (and all.zip)')
            log = gr.Textbox(label='Result', lines=4)

    opts = [layout, crop, anchor, orientation, margin, hues, steps, coding, analysis_px, simplify, smooth, min_region,
            contrast, gamma, tone_weight, vivid_auto, vivid, keep_transparent, seed, dpi, line_mm, spacing_mm, quality, black_ground, title,
            subtitle, rev, key_phrase]
    btn_prev.click(quick_preview, [files, grounds] + opts, gallery)
    btn_run.click(generate, [files, grounds, style, circle_bias, mix, tile_min, circle_max, ring_text, lettering, png2x, animate] + opts, [gallery, downloads, log])

if __name__ == '__main__':
    demo.queue().launch(theme=THEME, css=CSS)