Spaces:
Paused
Paused
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)
|