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
2026HAND STAMPED
philatelist studio
"""
SECTION_DIVIDER = """
"""
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)