import base64 import io import math import os import random import tempfile import gradio as gr import numpy as np from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps DEFAULT_COLOR = "#FF6B6B" # ── Helpers ─────────────────────────────────────────────────────────────────── def hex_rgb(h): h = h.strip() if h.startswith("rgb"): vals = h[h.index("(")+1:h.index(")")].split(",") return tuple(int(float(v.strip())) for v in vals[:3]) h = h.lstrip("#") return int(h[:2], 16), int(h[2:4], 16), int(h[4:6], 16) def to_b64(img): buf = io.BytesIO() img.save(buf, "PNG") return base64.b64encode(buf.getvalue()).decode() def load_font(size=11): for path in [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/System/Library/Fonts/Helvetica.ttc", ]: try: return ImageFont.truetype(path, size) except OSError: pass return ImageFont.load_default() # ── Stylisation ─────────────────────────────────────────────────────────────── def stylize(img, style, hex_color): r, g, b = hex_rgb(hex_color) img = img.convert("RGB") if style in ("engraving", "engraving ✦"): return ImageOps.autocontrast(ImageEnhance.Sharpness(img.convert("L")).enhance(3.0), cutoff=2).convert("RGB") if style == "halftone": from PIL import ImageFilter gray_arr = np.array(ImageOps.autocontrast(img.convert("L"), cutoff=1), dtype=np.float32) / 255.0 h, w = gray_arr.shape cell = 8 xs = np.arange(w) ys = np.arange(h) xmod = (xs % cell) - cell // 2 ymod = (ys % cell) - cell // 2 dot_arr = np.clip( np.sqrt(xmod[np.newaxis, :]**2 + ymod[:, np.newaxis]**2) / (cell / 2 * 0.9), 0, 1, ) gray_lifted = gray_arr * 0.75 + 0.25 blended = dot_arr * gray_lifted blurred = Image.fromarray((blended * 255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(radius=0.8) ) mask = (np.array(blurred, dtype=np.float32) / 255.0 > 0.5).astype(np.float32) out_arr = np.stack([ (mask * 255 + (1 - mask) * r).astype(np.uint8), (mask * 255 + (1 - mask) * g).astype(np.uint8), (mask * 255 + (1 - mask) * b).astype(np.uint8), ], axis=2) return Image.fromarray(out_arr, "RGB") if style == "duotone": gray = ImageOps.autocontrast(img.convert("L"), cutoff=2) gray = ImageOps.posterize(gray, 3) t = np.array(gray, dtype=np.float32) / 255.0 return Image.fromarray(np.dstack([ np.clip(t * 255 + (1-t) * r, 0, 255), np.clip(t * 255 + (1-t) * g, 0, 255), np.clip(t * 255 + (1-t) * b, 0, 255), ]).astype(np.uint8)) if style == "grayscale": return ImageOps.autocontrast(img.convert("L"), cutoff=2).convert("RGB") if style == "pop art": return ImageEnhance.Color(ImageOps.posterize(img, 2)).enhance(2.5) if style == "sepia": t = np.array(ImageOps.autocontrast(img.convert("L")), dtype=np.float32) / 255.0 return Image.fromarray(np.dstack([ np.clip(t*240,0,255), np.clip(t*200,0,255), np.clip(t*145,0,255), ]).astype(np.uint8)) if style == "linocut": gray = ImageOps.autocontrast(img.convert("L"), cutoff=8) t = (np.array(gray, dtype=np.float32) / 255.0 > 0.52).astype(np.float32) return Image.fromarray(np.stack([ (t * 255 + (1-t) * r).astype(np.uint8), (t * 255 + (1-t) * g).astype(np.uint8), (t * 255 + (1-t) * b).astype(np.uint8), ], axis=2)) if style == "risograph": from PIL import ImageFilter base = ImageOps.posterize(ImageOps.autocontrast(img.convert("L"), cutoff=2), 3) t = np.array(base, dtype=np.float32) / 255.0 grain = np.random.default_rng(42).normal(0, 0.06, t.shape) t = np.clip(t + grain, 0, 1) layer = Image.fromarray(np.stack([ np.clip(t * 255 + (1-t) * r, 0, 255).astype(np.uint8), np.clip(t * 255 + (1-t) * g, 0, 255).astype(np.uint8), np.clip(t * 255 + (1-t) * b, 0, 255).astype(np.uint8), ], axis=2)) offset = layer.transform(layer.size, Image.AFFINE, (1,0,4,0,1,3), fillcolor=(255,255,255)) return Image.blend(layer, offset, 0.18) if style == "cyanotype": gray = ImageOps.autocontrast(img.convert("L"), cutoff=2) t = np.array(gray, dtype=np.float32) / 255.0 return Image.fromarray(np.stack([ np.clip(t * 255 + (1-t) * 12, 0, 255).astype(np.uint8), np.clip(t * 255 + (1-t) * 68, 0, 255).astype(np.uint8), np.clip(t * 255 + (1-t) * 110, 0, 255).astype(np.uint8), ], axis=2)) if style == "vintage": gray = img.convert("L").convert("RGB") faded = Image.blend(img, gray, 0.55) a = np.array(faded, dtype=np.float32) a[:,:,0] = np.clip(a[:,:,0] * 1.08 + 12, 0, 255) a[:,:,1] = np.clip(a[:,:,1] * 1.02 + 4, 0, 255) a[:,:,2] = np.clip(a[:,:,2] * 0.82, 0, 255) a = a * 0.82 + 28 return Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)) return img # ── Stamp image ─────────────────────────────────────────────────────────────── def build_stamp(arr, hex_c, style, label, cancel="none", ink_hex="#14147A", ink_opacity=0.68): if arr is None: return None hex_c = hex_c or DEFAULT_COLOR r, g, b = hex_rgb(hex_c) W, H = 280, 330 label = label or "" BRD, LH, PR, PG = 22, (30 if label.strip() else 0), 5, 16 aw, ah = W - 2*BRD + 8, H - 2*BRD + 8 - LH photo = Image.fromarray(arr).convert("RGB") # Cover crop: scale to fill aw×ah, then center crop src_ratio = photo.width / photo.height area_ratio = aw / ah if src_ratio > area_ratio: new_h, new_w = ah, int(photo.width * ah / photo.height) else: new_w, new_h = aw, int(photo.height * aw / photo.width) photo = photo.resize((new_w, new_h), Image.LANCZOS) left, top = (new_w - aw) // 2, (new_h - ah) // 2 photo = photo.crop((left, top, left + aw, top + ah)) photo = stylize(photo, style, hex_c) stamp = Image.new("RGBA", (W, H), (r, g, b, 255)) draw = ImageDraw.Draw(stamp) draw.rectangle([BRD-4, BRD-4, W-BRD+3, H-BRD+3], fill=(255,255,255,255)) stamp.paste(photo.convert("RGBA"), (BRD - 4, BRD - 4)) if label.strip(): font = load_font(11) draw.text((W//2, H - BRD - LH//2 + 2), label.upper()[:24], fill=(r, g, b, 255), font=font, anchor="mm") if cancel and cancel != "none": import math overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) od = ImageDraw.Draw(overlay) ir, ig, ib = hex_rgb(ink_hex) alpha = int(ink_opacity * 255) ink = (ir, ig, ib, alpha) font_sm = load_font(8) if cancel == "postmark": cx, cy, cr = int(W*0.58), int(H*0.44), 46 od.ellipse([cx-cr, cy-cr, cx+cr, cy+cr], outline=ink, width=3) od.ellipse([cx-cr+7, cy-cr+7, cx+cr-7, cy+cr-7], outline=ink, width=1) for bar_y in [cy-16, cy-6, cy+4, cy+14]: pts = [(x, bar_y + 2.5*math.sin(0.38*x)) for x in range(cx-cr-2, cx+cr+55)] od.line(pts, fill=ink, width=2) od.text((cx, cy-cr+10), "MAR 2026", fill=ink, font=font_sm, anchor="mm") od.text((cx, cy+cr-10), "HAND STAMPED", fill=ink, font=font_sm, anchor="mm") elif cancel == "slogan": rx, ry, rw, rh = int(W*0.12), int(H*0.35), 175, 64 od.rectangle([rx, ry, rx+rw, ry+rh], outline=ink, width=2) od.line([(rx, ry+21), (rx+rw, ry+21)], fill=ink, width=1) od.line([(rx, ry+rh-21), (rx+rw, ry+rh-21)], fill=ink, width=1) od.text((rx+rw//2, ry+11), "FIRST CLASS MAIL", fill=ink, font=font_sm, anchor="mm") od.text((rx+rw//2, ry+rh//2), "★ MAR 2026 ★", fill=ink, font=load_font(9), anchor="mm") od.text((rx+rw//2, ry+rh-11), "HAND STAMPED", fill=ink, font=font_sm, anchor="mm") for bar_y in [ry+12, ry+24, ry+36, ry+48]: pts = [(x, bar_y + 2*math.sin(0.4*x)) for x in range(rx+rw+4, min(rx+rw+58, W-4))] od.line(pts, fill=ink, width=2) elif cancel == "bars": for i, bar_y in enumerate(range(int(H*0.28), int(H*0.72), 14)): amp = 3 if i % 2 == 0 else 2 pts = [(x, bar_y + amp*math.sin(0.3*x + i)) for x in range(BRD+4, W-BRD-4)] od.line(pts, fill=(ir, ig, ib, int(alpha * 0.8)), width=2) elif cancel == "roller": for i, x in enumerate(range(BRD+10, W-BRD-10, 12)): amp = 3 if i % 2 == 0 else 2 pts = [(x + amp*math.sin(0.28*y + i), y) for y in range(BRD+4, H-BRD-4)] od.line(pts, fill=(ir, ig, ib, int(alpha * 0.85)), width=2) for dx2, dy2 in [(1,0),(0,1)]: smudge = Image.new("RGBA", (W, H), (0,0,0,0)) smudge.paste(overlay, (dx2, dy2)) overlay = Image.alpha_composite(smudge, overlay) stamp = Image.alpha_composite(stamp, overlay) draw = ImageDraw.Draw(stamp) pc = (0, 0, 0, 0) for x in range(PG, W, PG): draw.ellipse([x-PR, -PR, x+PR, PR], fill=pc) draw.ellipse([x-PR, H-PR, x+PR, H+PR], fill=pc) for y in range(PG, H, PG): draw.ellipse([-PR, y-PR, PR, y+PR], fill=pc) draw.ellipse([W-PR, y-PR, W+PR, y+PR], fill=pc) return stamp # ── Gallery HTML ────────────────────────────────────────────────────────────── _DATES = ["MAR 2026", "FEB 2026", "JAN 2026", "DEC 2025", "NOV 2025"] def gallery_html(stamps): style_block = """""" if not stamps: return f"""{style_block}

your collection is waiting

""" cards = "" mk_colors = ["rgba(120,20,20,.55)", "rgba(20,40,120,.5)", "rgba(20,80,30,.5)"] for i, s in enumerate(stamps): date = _DATES[i % len(_DATES)] mk_c = mk_colors[i % len(mk_colors)] cards += ( f'
' f'' f'
' ) n = len(stamps) return f"""{style_block}
collection {n:02d} stamp{"s" if n!=1 else ""}
{cards}
""" # ── App logic ───────────────────────────────────────────────────────────────── def preview_stamp(arr, color, style, label, cancel, ink_hex, ink_opacity): return build_stamp(arr, color, style, label, cancel, ink_hex, ink_opacity) def add_stamp(arr, color, style, label, stamps, cancel, ink_hex, ink_opacity): img = build_stamp(arr, color, style, label, cancel, ink_hex, ink_opacity) if img is None: return stamps, gallery_html(stamps) rot = round(random.uniform(-6, 6), 1) updated = stamps + [{"b64": to_b64(img), "rot": rot, "label": label.strip(), "color": color}] return updated, gallery_html(updated) def clear_stamps(_state): return [], gallery_html([]), gr.DownloadButton(visible=False) def export_collection(stamps): if not stamps: return None cols = min(3, len(stamps)) rows = math.ceil(len(stamps) / cols) pad, sw, sh = 48, 280, 330 sheet = Image.new("RGB", (cols * sw + (cols + 1) * pad, rows * sh + (rows + 1) * pad), (247, 240, 230)) for i, s in enumerate(stamps): row, col = divmod(i, cols) x = pad + col * (sw + pad) y = pad + row * (sh + pad) stamp_img = Image.open(io.BytesIO(base64.b64decode(s["b64"]))).convert("RGBA") rotated = stamp_img.rotate(s["rot"], expand=True, resample=Image.BICUBIC) px = x + (sw - rotated.width) // 2 py = y + (sh - rotated.height) // 2 sheet.paste(rotated, (px, py), rotated) tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) sheet.save(tmp.name, "PNG") return tmp.name # ── CSS ── (layout + custom components only; colours handled by theme) ───────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Space+Mono:wght@400;700&family=DM+Sans:wght@400;500;600&display=swap'); footer { display: none !important; } .gradio-container { max-width: 1080px !important; margin: 0 auto !important; padding: 0 !important; background: #F7F0E6 !important; } body, .main, #root { background: #F7F0E6 !important; } .block, .form { background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; overflow: visible !important; } .wrap, .panel, .tabs, .tabitem, .tab-nav { background: #F7F0E6 !important; } textarea, input[type=text], input[type=number] { background: white !important; color: #3D2E1E !important; } select, .dropdown { background: white !important; color: #3D2E1E !important; } .gap, .contain, .row { overflow: visible !important; } .gap, .contain { gap: 16px !important; } /* ─── Image upload zone ─── */ [data-testid="image"], [data-testid="image"] > div, [data-testid="image"] .wrap, [data-testid="image"] .image-container { background: #F0E8D8 !important; } [data-testid="image"] * { color: #3D2E1E !important; } [data-testid="image"] .wrap { border: 2px dashed #C4B49A !important; border-radius: 12px !important; width: 100% !important; box-sizing: border-box !important; position: relative !important; left: auto !important; top: auto !important; transform: none !important; } [data-testid="image"] .wrap > div { width: 100% !important; height: 100% !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; } [data-testid="image"] .source-selection { background: rgba(240,232,216,.9) !important; border-top: 1px solid #D4C3A8 !important; } [data-testid="image"] .label-wrap, .preview-img .label-wrap { display: none !important; } [data-testid="image"] button, [data-testid="image"] .toolbar { background: transparent !important; border: none !important; box-shadow: none !important; color: #8A7660 !important; } /* ─── Labels ─── */ .gradio-container { --block-label-text-color: #3D2E1E !important; --block-title-text-color: #3D2E1E !important; --body-text-color: #3D2E1E !important; --body-text-color-subdued: #3D2E1E !important; } label > span, fieldset > span, [data-testid="radio-group"] > span, .container > span:first-child, [data-testid="color-picker"] label, [data-testid="color-picker"] span, span[data-testid="block-info"], .gradio-container label, .gradio-container label span, .block label, .block label span { font-size: .65rem !important; font-weight: 700 !important; letter-spacing: .14em !important; text-transform: uppercase !important; color: #3D2E1E !important; } /* ─── Radio pills ─── */ .style-pick { padding-bottom: 12px !important; } .style-pick .wrap { display: flex !important; gap: 6px !important; flex-wrap: wrap !important; } .style-pick .wrap label { padding: 5px 14px !important; border-radius: 6px !important; border: 1.5px solid #D4C3A8 !important; background: white !important; cursor: pointer !important; font-size: .68rem !important; font-weight: 700 !important; letter-spacing: .06em !important; color: #2A1E10 !important; transition: all .15s !important; user-select: none !important; } .style-pick .wrap label:hover { background: #F5EDD9 !important; } .style-pick .wrap label:has(input:checked) { background: #5C3A1E !important; color: #E8C97A !important; border-color: #5C3A1E !important; } .style-pick .wrap label:has(input:checked) span { color: #E8C97A !important; } .style-pick .wrap label input[type=radio] { display: none !important; } /* ─── Buttons ─── */ button.primary { letter-spacing: .1em !important; text-transform: uppercase !important; box-shadow: 3px 3px 0px #8A7660 !important; transition: transform .12s, box-shadow .12s !important; } button.primary:hover { transform: translate(-1px,-1px) !important; box-shadow: 4px 4px 0px #8A7660 !important; } button.primary:active { transform: translate(2px,2px) !important; box-shadow: 1px 1px 0px #8A7660 !important; } button.secondary { letter-spacing: .08em !important; text-transform: uppercase !important; } """ # ── Decorative HTML pieces ──────────────────────────────────────────────────── HEADER_HTML = """
PAR AVION · AIRMAIL · 航空郵便
stamp
maker
✦ turn photos into collectibles ✦
FIRST CLASS
2026
HAND STAMPED
philatelist studio
""" SECTION_DIVIDER = """
your collection
""" CONTROLS_HEADER = """
── studio controls ──
""" PREVIEW_HEADER = """
── preview ──
""" from theme import Philatelist # ── Theme ───────────────────────────────────────────────────────────────────── _theme = Philatelist() with gr.Blocks(title="stamp maker ✦") as demo: state = gr.State([]) gr.HTML(HEADER_HTML) with gr.Row(equal_height=False): # ── Controls ── with gr.Column(scale=5, min_width=300, elem_id="controls-col"): gr.HTML(CONTROLS_HEADER) img_in = gr.Image( label="photo", type="numpy", sources=["upload", "webcam"], height=220, show_label=False, ) color_in = gr.ColorPicker( value=DEFAULT_COLOR, label="colour", ) style_in = gr.Radio( choices=["original", "duotone", "grayscale", "pop art", "sepia", "halftone", "engraving ✦", "linocut", "risograph", "cyanotype", "vintage"], value="duotone", label="style", elem_classes=["style-pick"], ) label_in = gr.Textbox( label="stamp label", show_label=False, placeholder="stamp label e.g. tokyo / 2026", max_lines=1, ) cancel_in = gr.Radio( choices=["none", "postmark", "slogan", "bars", "roller"], value="none", label="cancellation", elem_classes=["style-pick"], ) with gr.Row(): ink_in = gr.ColorPicker( value="#14147A", label="ink colour", ) opacity_in = gr.Slider( minimum=0.1, maximum=1.0, value=0.68, step=0.05, label="ink opacity", ) with gr.Row(): add_btn = gr.Button("add to collection ✦", variant="primary", scale=3) clear_btn = gr.Button("clear all", variant="secondary", scale=1) # ── Preview ── with gr.Column(scale=4, min_width=260): gr.HTML(PREVIEW_HEADER) preview_out = gr.Image( label="stamp preview", type="pil", format="png", interactive=False, show_label=False, height=380, buttons=["download"], elem_classes=["preview-img"], ) gr.HTML(SECTION_DIVIDER) gallery_out = gr.HTML(value=gallery_html([])) export_btn = gr.DownloadButton("download collection ↓", variant="secondary", visible=False) # ── Events ── for ctrl in [img_in, color_in, style_in, label_in, cancel_in, ink_in, opacity_in]: ctrl.change( fn=preview_stamp, inputs=[img_in, color_in, style_in, label_in, cancel_in, ink_in, opacity_in], outputs=preview_out, ) add_btn.click( fn=add_stamp, inputs=[img_in, color_in, style_in, label_in, state, cancel_in, ink_in, opacity_in], outputs=[state, gallery_out], ).then( fn=lambda stamps: gr.DownloadButton(value=export_collection(stamps), visible=True), inputs=[state], outputs=export_btn, ) clear_btn.click(fn=clear_stamps, inputs=[state], outputs=[state, gallery_out, export_btn]) if __name__ == "__main__": demo.launch(css=CSS, theme=_theme)