Spaces:
Sleeping
Sleeping
| 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 = """<style> | |
| @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'); | |
| .felt-board { | |
| background: #F7F0E6; | |
| background-image: | |
| repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(0,0,0,.03) 39px, rgba(0,0,0,.03) 40px), | |
| repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(0,0,0,.03) 39px, rgba(0,0,0,.03) 40px); | |
| min-height: 460px; | |
| border-radius: 12px; | |
| padding: 48px 36px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 36px; | |
| align-items: flex-start; | |
| position: relative; | |
| border: 1px solid #D4C3A8; | |
| box-shadow: none; | |
| } | |
| .felt-board::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 6px; | |
| border: 1px solid rgba(255,255,255,.04); | |
| border-radius: 8px; | |
| pointer-events: none; | |
| } | |
| .stamp-pin { | |
| transform: rotate(var(--rot, 0deg)); | |
| transition: transform .35s cubic-bezier(.34,1.56,.64,1), filter .35s ease; | |
| cursor: pointer; | |
| position: relative; | |
| } | |
| .stamp-pin:hover { | |
| transform: rotate(0deg) scale(1.12) translateY(-10px) !important; | |
| z-index: 100; | |
| } | |
| .stamp-pin img { display: block; width: 122px; border-radius: 1px; } | |
| .postmark { | |
| position: absolute; | |
| top: 50%; left: 55%; | |
| transform: translate(-50%, -50%) rotate(-22deg); | |
| width: 58px; height: 58px; | |
| border: 2.5px solid var(--mk-color, rgba(40,10,10,.55)); | |
| border-radius: 50%; | |
| display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| pointer-events: none; | |
| opacity: var(--mk-vis, 0); | |
| transition: opacity .2s; | |
| font-family: 'Space Mono', monospace; | |
| font-size: 5.5px; | |
| font-weight: 700; | |
| letter-spacing: .06em; | |
| color: var(--mk-color, rgba(40,10,10,.55)); | |
| line-height: 1.4; | |
| text-align: center; | |
| text-transform: uppercase; | |
| } | |
| .postmark::before, .postmark::after { | |
| content: 'β β β β'; | |
| font-size: 5px; | |
| letter-spacing: 1px; | |
| display: block; | |
| color: inherit; | |
| opacity: .7; | |
| } | |
| .stamp-pin:hover .postmark { opacity: 1; } | |
| .board-empty { | |
| width: 100%; | |
| display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; | |
| min-height: 360px; | |
| gap: 14px; | |
| font-family: 'DM Sans', system-ui; | |
| color: #3A4F35; | |
| } | |
| .board-empty .empty-icon { font-size: 2.2rem; opacity: .6; } | |
| .board-empty p { font-size: .85rem; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; opacity: .5; margin: 0; } | |
| .collection-bar { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 0 2px 10px; | |
| } | |
| .collection-bar .col-label { | |
| font-family: 'Space Mono', monospace; | |
| font-size: .68rem; | |
| font-weight: 700; | |
| letter-spacing: .12em; | |
| text-transform: uppercase; | |
| color: #3A4F35; | |
| } | |
| .collection-bar .col-count { | |
| font-family: 'Space Mono', monospace; | |
| font-size: .68rem; | |
| color: #3A4F35; | |
| letter-spacing: .08em; | |
| } | |
| </style>""" | |
| if not stamps: | |
| return f"""{style_block} | |
| <div class="felt-board"> | |
| <div class="board-empty"> | |
| <div class="empty-icon">β</div> | |
| <p>your collection is waiting</p> | |
| </div> | |
| </div>""" | |
| 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'<div class="stamp-pin" style="--rot:{s["rot"]}deg">' | |
| f'<img src="data:image/png;base64,{s["b64"]}" />' | |
| f'</div>' | |
| ) | |
| n = len(stamps) | |
| return f"""{style_block} | |
| <div class="collection-bar"> | |
| <span class="col-label">collection</span> | |
| <span class="col-count">{n:02d} stamp{"s" if n!=1 else ""}</span> | |
| </div> | |
| <div class="felt-board">{cards}</div>""" | |
| # ββ 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 = """ | |
| <div style="font-family:'Space Mono',monospace;padding:0 0 28px;"> | |
| <!-- top rule --> | |
| <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;opacity:.4"> | |
| <div style="flex:1;height:1px;background:repeating-linear-gradient(90deg,#8A7660 0,#8A7660 6px,transparent 6px,transparent 12px)"></div> | |
| <span style="font-size:.6rem;letter-spacing:.18em;color:#8A7660;white-space:nowrap">PAR AVION Β· AIRMAIL Β· θͺη©Ίι΅δΎΏ</span> | |
| <div style="flex:1;height:1px;background:repeating-linear-gradient(90deg,#8A7660 0,#8A7660 6px,transparent 6px,transparent 12px)"></div> | |
| </div> | |
| <div style="display:flex;align-items:flex-end;justify-content:space-between;gap:24px;flex-wrap:wrap;"> | |
| <!-- title block --> | |
| <div> | |
| <div style="font-family:'DM Serif Display',Georgia,serif;font-size:3.2rem;line-height:.95;letter-spacing:-.02em;color:#5C3A1E"> | |
| stamp<br>maker | |
| </div> | |
| <div style="margin-top:10px;font-size:.62rem;letter-spacing:.18em;color:#8A7660;text-transform:uppercase"> | |
| β¦ turn photos into collectibles β¦ | |
| </div> | |
| </div> | |
| <!-- postmark decoration --> | |
| <div style="display:flex;gap:16px;align-items:center;opacity:.65;flex-shrink:0"> | |
| <div style="width:72px;height:72px;border:2.5px solid #8A7660;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:5.5px;letter-spacing:.1em;color:#6A5A45;text-align:center;line-height:1.7;text-transform:uppercase;position:relative"> | |
| <div style="position:absolute;top:-1px;left:50%;transform:translateX(-50%);width:80%;height:2px;background:#F7F0E6"></div> | |
| <div style="position:absolute;bottom:-1px;left:50%;transform:translateX(-50%);width:80%;height:2px;background:#F7F0E6"></div> | |
| FIRST CLASS<br><span style="font-size:7px;font-weight:700">2026</span><br>HAND STAMPED | |
| </div> | |
| <div style="display:flex;flex-direction:column;gap:3px"> | |
| <div style="width:48px;height:1.5px;background:#8A7660;border-radius:1px"></div> | |
| <div style="width:40px;height:1.5px;background:#8A7660;border-radius:1px"></div> | |
| <div style="width:44px;height:1.5px;background:#8A7660;border-radius:1px"></div> | |
| </div> | |
| <div style="writing-mode:vertical-rl;font-size:.55rem;letter-spacing:.2em;color:#8A7660;text-transform:uppercase;opacity:.8"> | |
| philatelist studio | |
| </div> | |
| </div> | |
| </div> | |
| <!-- bottom rule --> | |
| <div style="margin-top:20px;display:flex;align-items:center;gap:12px;opacity:.35"> | |
| <div style="flex:1;border-top:1.5px solid #8A7660"></div> | |
| <div style="width:6px;height:6px;border:1.5px solid #8A7660;border-radius:50%"></div> | |
| <div style="flex:1;border-top:1.5px solid #8A7660"></div> | |
| </div> | |
| </div> | |
| """ | |
| SECTION_DIVIDER = """ | |
| <div style="margin:8px 0 4px;display:flex;align-items:center;gap:10px;"> | |
| <div style="flex:1;height:1px;background:repeating-linear-gradient(90deg,#8A7660 0,#8A7660 4px,transparent 4px,transparent 8px);opacity:.3"></div> | |
| <span style="font-family:'Space Mono',monospace;font-size:.55rem;letter-spacing:.2em;color:#3D2E1E;text-transform:uppercase;white-space:nowrap;">your collection</span> | |
| <div style="flex:1;height:1px;background:repeating-linear-gradient(90deg,#8A7660 0,#8A7660 4px,transparent 4px,transparent 8px);opacity:.3"></div> | |
| </div> | |
| """ | |
| CONTROLS_HEADER = """ | |
| <div style="position:relative;z-index:10;display:inline-block;background:#F7F0E6;padding:0 8px 0 0;font-family:'Space Mono',monospace;font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;color:#3D2E1E;"> | |
| ββ studio controls ββ | |
| </div> | |
| """ | |
| PREVIEW_HEADER = """ | |
| <div style="font-family:'Space Mono',monospace;font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;color:#3D2E1E;margin-bottom:2px;"> | |
| ββ preview ββ | |
| </div> | |
| """ | |
| 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) | |