stamp-maker / app.py
hmb's picture
hmb HF Staff
Fix label colours for ColorPicker components
099043f
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)