Spaces:
Paused
Paused
Download app.py from taellinglin/HatchLing: direct link, hf CLI and curl.
- Browser
- Download file 14.7 kB
-
https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/app.py
- Command line
-
hf download hf://spaces/taellinglin/HatchLing/app.py
-
curl -L -o app.py https://huggingface.co/spaces/taellinglin/HatchLing/resolve/main/app.py
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) | |