Ahadhassan-2003
deploy: update HF Space
dc4e6da
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageChops
import PIL
import PIL.Image
import numpy as np
import math
import random
from typing import Tuple, Optional
def _hex_to_rgb(hex_color: str):
hex_color = hex_color.lstrip("#")
lv = len(hex_color)
return tuple(int(hex_color[i : i + lv // 3], 16) for i in range(0, lv, lv // 3))
def _make_noise_image(size, mean=0.7, std=0.22, blur=2, contrast=1.0):
"""Return L-mode noise image (0-255)."""
w, h = size
arr = np.clip(np.random.normal(loc=mean, scale=std, size=(h, w)), 0.0, 1.0)
img = Image.fromarray((arr * 255).astype(np.uint8), mode="L")
if blur > 0:
img = img.filter(ImageFilter.GaussianBlur(blur))
if contrast != 1.0:
a = np.asarray(img).astype(np.float32)
a = 128 + (a - 128) * contrast
a = np.clip(a, 0, 255).astype(np.uint8)
img = Image.fromarray(a, mode="L")
return img
def _bias_noise_towards_opaque(noise_img: Image.Image, min_val=200):
"""
Bias a noise image so values fall in [min_val..255], preserving local variation
but ensuring the noise doesn't make the stamp too transparent.
"""
assert 0 <= min_val <= 255
return noise_img.point(lambda p: min_val + (p * (255 - min_val) // 255))
def _draw_text_on_arc(
target_img: Image.Image,
text: str,
center: Tuple[int, int],
radius: float,
font: ImageFont.FreeTypeFont,
color: Tuple[int, int, int, int],
start_angle_deg: float = 0.0,
inward: bool = False,
):
"""
Draw text along an arc centered at `center` with given `radius`.
Characters are placed and rotated tangentially for realism.
"""
draw = ImageDraw.Draw(target_img)
# measure each character width using textbbox
char_widths = []
for ch in text:
bbox = draw.textbbox((0, 0), ch, font=font)
w = bbox[2] - bbox[0]
char_widths.append(max(w, 1))
angs = [(w / radius) * (180.0 / math.pi) for w in char_widths]
total_arc = sum(angs)
angle = start_angle_deg - total_arc / 2.0
cx, cy = center
for i, ch in enumerate(text):
char_ang = angs[i]
angle += char_ang / 2.0
theta = math.radians(angle)
x = cx + radius * math.cos(theta)
y = cy + radius * math.sin(theta)
bbox = draw.textbbox((0, 0), ch, font=font)
cw = bbox[2] - bbox[0]
chh = bbox[3] - bbox[1]
pad = int(max(cw, chh) * 1.6) + 6
char_img = Image.new("RGBA", (pad, pad), (0, 0, 0, 0))
cd = ImageDraw.Draw(char_img)
cd.text((pad // 2, pad // 2), ch, font=font, fill=color, anchor="mm")
rot_angle = -angle + 90
if inward:
rot_angle += 180
rot = char_img.rotate(rot_angle, resample=Image.BICUBIC, expand=True)
px = int(x - rot.width / 2)
py = int(y - rot.height / 2)
target_img.paste(rot, (px, py), rot)
angle += char_ang / 2.0
def _wrap_text_to_fit(text, font, max_width):
"""
Automatically wrap text by inserting line breaks to fit within max_width.
Returns text with line breaks inserted.
"""
# If text already has line breaks, process each line separately
existing_lines = text.split("\n")
wrapped_lines = []
temp_img = Image.new("RGBA", (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
for line in existing_lines:
words = line.split()
if not words:
wrapped_lines.append("")
continue
current_line = []
for word in words:
test_line = " ".join(current_line + [word])
bbox = temp_draw.textbbox((0, 0), test_line, font=font)
width = bbox[2] - bbox[0]
if width <= max_width:
current_line.append(word)
else:
if current_line:
wrapped_lines.append(" ".join(current_line))
current_line = [word]
else:
# Single word is too long, just add it anyway
wrapped_lines.append(word)
current_line = []
if current_line:
wrapped_lines.append(" ".join(current_line))
return "\n".join(wrapped_lines)
def create_realistic_stamp(
text_top: str = "APPROVED",
text_bottom: Optional[str] = None,
inner_text: Optional[str] = None,
shape: str = "circle", # "circle" or "rectangle"
size: Tuple[int, int] = (800, 800), # final (width, height)
color: str = "#C42828", # hex or "r,g,b"
border_thickness_ratio: float = 0.08, # relative to min(width,height)
font_path: Optional[str] = None,
font_size: Optional[int] = None, # base font size
random_seed: Optional[int] = None,
supersample: int = 3, # supersampling factor
rot_angle: float | None = None,
):
"""
Generate a realistic-looking stamp PNG with transparent background.
- Automatically adjusts font size to fit text
- Fixes text cutoff issues
"""
if random_seed is not None:
random.seed(random_seed)
np.random.seed(random_seed)
w, h = size
scale = max(1, int(supersample))
W, H = w * scale, h * scale
if isinstance(color, str):
if "," in color:
color_rgb = tuple(int(x) for x in color.split(","))
else:
color_rgb = _hex_to_rgb(color)
else:
color_rgb = tuple(color)
# big canvas (supersampled)
stamp = Image.new("RGBA", (W, H), (0, 0, 0, 0))
shape_layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d_shape = ImageDraw.Draw(shape_layer)
min_side = min(W, H)
border_w = max(2 * scale, int(min_side * border_thickness_ratio))
jitter_x = random.randint(-int(min_side * 0.005), int(min_side * 0.005))
jitter_y = random.randint(-int(min_side * 0.005), int(min_side * 0.005))
# Draw the ring/rectangle onto shape_layer
if shape.lower() == "circle":
outer = [
(border_w // 2 + jitter_x, border_w // 2 + jitter_y),
(W - border_w // 2 + jitter_x, H - border_w // 2 + jitter_y),
]
inner = [
(border_w * 3 + jitter_x, border_w * 3 + jitter_y),
(W - border_w * 3 + jitter_x, H - border_w * 3 + jitter_y),
]
for i in range(border_w):
off = random.randint(-scale, scale)
d_shape.ellipse(
[
(outer[0][0] + i + off, outer[0][1] + i + off),
(outer[1][0] - i + off, outer[1][1] - i + off),
],
outline=color_rgb + (255,),
)
d_shape.ellipse(inner, outline=color_rgb + (220,), width=max(1, border_w // 6))
else:
pad = border_w // 2
for i in range(border_w):
off = random.randint(-scale, scale)
rect = [
pad + i + off + jitter_x,
pad + i + off + jitter_y,
W - (pad + i) + jitter_x,
H - (pad + i) + jitter_y,
]
d_shape.rounded_rectangle(
rect, radius=max(6 * scale, border_w), outline=color_rgb + (255,)
)
# Blur the shape layer
bleed_radius = max(1.0 * scale, scale * 0.9)
shape_layer = shape_layer.filter(ImageFilter.GaussianBlur(radius=bleed_radius))
stamp.alpha_composite(shape_layer, (0, 0))
# Font loading helper
def _try_load_ttf(desired_size):
try:
if font_path:
return ImageFont.truetype(font_path, desired_size)
else:
return ImageFont.truetype("DejaVuSans-Bold.ttf", desired_size)
except Exception:
return ImageFont.load_default()
# Calculate available space for inner text
if inner_text:
# Define text area boundaries
if shape.lower() == "circle":
# For circle: use area inside inner ring
text_area_width = W - (border_w * 6)
text_area_height = H - (border_w * 6)
else:
# For rectangle: use area inside borders with padding
text_area_width = W - (border_w * 4)
text_area_height = H - (border_w * 4)
# Calculate initial font size
if font_size:
inner_font_size = int(font_size * 1.6 * scale)
else:
inner_font_size = int(min_side * 0.20)
inner_font = _try_load_ttf(inner_font_size)
# Wrap text to fit width
inner_text = _wrap_text_to_fit(inner_text, inner_font, text_area_width * 0.95)
# Small font for curved text
if font_size:
small_font_size = max(10 * scale, int(font_size * 0.6 * scale))
else:
small_font_size = max(10 * scale, int(min_side * 0.055))
small_font = _try_load_ttf(small_font_size)
d = ImageDraw.Draw(stamp)
# Curved text (circle)
if shape.lower() == "circle" and text_top:
center = (W // 2 + jitter_x, H // 2 + jitter_y)
radius = (min_side // 2) - border_w - int(min_side * 0.03)
_draw_text_on_arc(
stamp,
text_top.upper(),
center,
radius,
small_font,
color_rgb + (255,),
start_angle_deg=-90,
)
if text_bottom:
_draw_text_on_arc(
stamp,
text_bottom.upper(),
center,
radius,
small_font,
color_rgb + (255,),
start_angle_deg=90,
inward=True,
)
# Inner/center text - FIXED VERTICAL POSITIONING
if inner_text:
centerx, centery = W // 2 + jitter_x, H // 2 + jitter_y
lines = inner_text.split("\n")
# Calculate total height and individual line metrics
draw_tmp = ImageDraw.Draw(stamp)
line_metrics = []
total_h = 0
for ln in lines:
bbox = draw_tmp.textbbox((0, 0), ln, font=inner_font)
# Use actual bbox for accurate height including descenders
line_height = bbox[3] - bbox[1]
line_metrics.append(
{
"text": ln,
"bbox": bbox,
"width": bbox[2] - bbox[0],
"height": line_height,
"y_offset": -bbox[1], # Offset to account for font baseline
}
)
total_h += line_height
# Start from top, centered vertically
y = centery - total_h // 2
for metric in line_metrics:
ln = metric["text"]
tw = metric["width"]
th = metric["height"]
y_off = metric["y_offset"]
# Create image with extra padding to prevent cutoff
padding = 30
txt_img = Image.new(
"RGBA", (tw + padding * 2, th + padding * 2), (0, 0, 0, 0)
)
td = ImageDraw.Draw(txt_img)
# Draw text with proper baseline offset
td.text(
(padding, padding + y_off), ln, font=inner_font, fill=color_rgb + (255,)
)
angle = random.uniform(-1.0, 1.0)
txt_img = txt_img.rotate(angle, resample=Image.BICUBIC, expand=True)
paste_x = int(centerx - txt_img.width / 2)
paste_y = int(y - padding)
stamp.paste(txt_img, (paste_x, paste_y), txt_img)
y += th
# Add subtle overlay strokes
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
if shape.lower() == "circle":
try:
od.ellipse(
[(border_w, border_w), (W - border_w, H - border_w)],
outline=color_rgb + (180,),
width=max(1, border_w // 6),
)
except Exception:
pass
else:
try:
od.rounded_rectangle(
[border_w, border_w, W - border_w, H - border_w],
radius=max(6 * scale, border_w),
outline=color_rgb + (180,),
width=max(1, border_w // 6),
)
except Exception:
pass
stamp.alpha_composite(overlay)
# Add noise texture
noise = _make_noise_image(
(W, H), mean=0.78, std=0.18, blur=2 * scale, contrast=1.05
)
noise_biased = _bias_noise_towards_opaque(noise, min_val=210)
orig_alpha = stamp.split()[-1]
new_alpha = ImageChops.multiply(orig_alpha, noise_biased)
a_arr = np.asarray(new_alpha).astype(np.float32)
a_arr = np.clip(a_arr * 1.03, 0, 255).astype(np.uint8)
new_alpha = Image.fromarray(a_arr, mode="L")
stamp.putalpha(new_alpha)
# Slight blur for ink bleed effect
stamp = stamp.filter(ImageFilter.GaussianBlur(radius=0.4 * scale))
# Add light speckle holes
speck = _make_noise_image((W, H), mean=0.5, std=0.9, blur=0.6 * scale, contrast=1.6)
speck_arr = np.asarray(speck)
speck_mask = (speck_arr > 252).astype(np.uint8) * 255
speck_img = Image.fromarray(speck_mask, mode="L")
if speck_img.getbbox() is not None:
alpha = stamp.split()[-1]
alpha = ImageChops.subtract(alpha, speck_img)
stamp.putalpha(alpha)
# Random rotation
rot_angle = rot_angle or random.uniform(-2.2, 2.2)
stamp = stamp.rotate(rot_angle, resample=Image.Resampling.BICUBIC, expand=True)
# Downsample to final size
final = stamp.resize((w, h), resample=Image.Resampling.LANCZOS)
# Final sharpening
final = final.filter(ImageFilter.UnsharpMask(radius=0.6, percent=120, threshold=2))
return final
def create_stamp_alt(text: str) -> PIL.Image.Image:
coin = random.random() <= 0.5
if coin:
return create_realistic_stamp(
"",
text_bottom="",
inner_text=text,
shape="circle",
size=(900, 900),
color="#a81f1f",
font_path=None,
font_size=60,
random_seed=42,
supersample=3,
)
else:
return create_realistic_stamp(
text_top="",
inner_text=text,
shape="rectangle",
size=(1100, 500),
color="#1f7a1f",
font_size=56,
random_seed=7,
supersample=3,
)
def create_stamp(
text: str, width: float, height: float, rot_angle: float | None
) -> PIL.Image.Image:
coin = random.random() <= 0.5
width = int(width)
height = int(height)
size_mult = 11 # previous default values were along 900/1000, but real sizes are around 100, which the text resizing cant handle
if coin:
return create_realistic_stamp(
"",
text_bottom="",
inner_text=text,
shape="circle",
size=(width * size_mult, height * size_mult),
color="#a81f1f",
font_path=None,
font_size=60,
random_seed=42,
supersample=3,
rot_angle=rot_angle,
)
else:
return create_realistic_stamp(
text_top="",
inner_text=text,
shape="rectangle",
size=(width * size_mult, height * size_mult),
color="#1f7a1f",
font_size=56,
random_seed=7,
supersample=3,
rot_angle=rot_angle,
)