#!/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)