HatchLing / app.py
taellinglin's picture
Claude Sonnet 5.5
Initial commit: Ling hatch with red/blue/green/black Gradio theme
68972cd
Raw History Blame Contribute Delete
14.7 kB
#!/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)