| 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)
|
|
|
| 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.
|
| """
|
|
|
| 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:
|
|
|
| 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",
|
| size: Tuple[int, int] = (800, 800),
|
| color: str = "#C42828",
|
| border_thickness_ratio: float = 0.08,
|
| font_path: Optional[str] = None,
|
| font_size: Optional[int] = None,
|
| random_seed: Optional[int] = None,
|
| supersample: int = 3,
|
| 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)
|
|
|
|
|
| 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))
|
|
|
|
|
| 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,)
|
| )
|
|
|
|
|
| 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))
|
|
|
|
|
| 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()
|
|
|
|
|
| if inner_text:
|
|
|
| if shape.lower() == "circle":
|
|
|
| text_area_width = W - (border_w * 6)
|
| text_area_height = H - (border_w * 6)
|
| else:
|
|
|
| text_area_width = W - (border_w * 4)
|
| text_area_height = H - (border_w * 4)
|
|
|
|
|
| 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)
|
|
|
|
|
| inner_text = _wrap_text_to_fit(inner_text, inner_font, text_area_width * 0.95)
|
|
|
|
|
| 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)
|
|
|
|
|
| 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,
|
| )
|
|
|
|
|
| if inner_text:
|
| centerx, centery = W // 2 + jitter_x, H // 2 + jitter_y
|
| lines = inner_text.split("\n")
|
|
|
|
|
| draw_tmp = ImageDraw.Draw(stamp)
|
| line_metrics = []
|
| total_h = 0
|
|
|
| for ln in lines:
|
| bbox = draw_tmp.textbbox((0, 0), ln, font=inner_font)
|
|
|
| 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],
|
| }
|
| )
|
| total_h += line_height
|
|
|
|
|
| y = centery - total_h // 2
|
|
|
| for metric in line_metrics:
|
| ln = metric["text"]
|
| tw = metric["width"]
|
| th = metric["height"]
|
| y_off = metric["y_offset"]
|
|
|
|
|
| padding = 30
|
| txt_img = Image.new(
|
| "RGBA", (tw + padding * 2, th + padding * 2), (0, 0, 0, 0)
|
| )
|
| td = ImageDraw.Draw(txt_img)
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
| 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)
|
|
|
|
|
| stamp = stamp.filter(ImageFilter.GaussianBlur(radius=0.4 * scale))
|
|
|
|
|
| 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)
|
|
|
|
|
| rot_angle = rot_angle or random.uniform(-2.2, 2.2)
|
| stamp = stamp.rotate(rot_angle, resample=Image.Resampling.BICUBIC, expand=True)
|
|
|
|
|
| final = stamp.resize((w, h), resample=Image.Resampling.LANCZOS)
|
|
|
|
|
| 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
|
| 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,
|
| )
|
|
|