272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""
|
|
Kuromi techno watermark — visible brand wordmark for transquinnftw.com photos.
|
|
|
|
Renders the full site URL `transquinnftw.com` in a neon-pink techno wordmark
|
|
(Kuromi palette: electric pink + black) with a thin black stroke, a soft pink
|
|
neon glow, and a subtle semi-opaque black rounded plate so it stays legible
|
|
over light, dark, and busy backgrounds — at both thumbnail and full resolution.
|
|
|
|
Deterministic and idempotent: same input bytes + same params -> same output.
|
|
Never mutates inputs; callers write to a separate output tree.
|
|
|
|
Font: Orbitron (variable, OFL) / Audiowide (OFL). See fonts/*-OFL.txt for the
|
|
license and LEDGER.md for provenance.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
FONT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts")
|
|
|
|
WATERMARK_TEXT = "transquinnftw.com"
|
|
|
|
# Kuromi palette — SHARED BRAND SOURCE OF TRUTH.
|
|
# These MUST match the canonical hex in the provider-site theme tokens:
|
|
# deployments/@domains/quinn.www/root/src/themes/kuromi-tokens.ts
|
|
# (pink #FF2E97 → PINK, pinkGlow #FF268A → PINK_GLOW, black #08080C → BLACK)
|
|
# This is Python with no cross-language import; keep the two in lockstep by
|
|
# hand and update both when the brand palette changes.
|
|
PINK = (255, 46, 151) # #FF2E97 — electric hot pink — the wordmark fill
|
|
PINK_GLOW = (255, 38, 138) # #FF268A — slightly deeper pink for the neon halo
|
|
BLACK = (8, 8, 12) # #08080C — near-black for stroke + plate
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WatermarkStyle:
|
|
"""Tunable look. Lengths are fractions of the image width unless noted.
|
|
|
|
mode controls crop-resistance:
|
|
"corner" — small mark bottom-right/center (clippable; least intrusive)
|
|
"center" — one large wordmark over the subject (hard to crop out)
|
|
"diagonal-band" — one large rotated wordmark across the frame
|
|
"diagonal-tile" — repeated rotated wordmark tiled over the whole image
|
|
(most crop-resistant)
|
|
"edge-rail" — wordmark running vertically up a side margin at mid-height
|
|
(off the subject, but mid-height so a crop can't drop it)
|
|
"dual-corner" — two corner marks (top-left + bottom-right); keeps the
|
|
corner look but one crop can't remove both
|
|
"""
|
|
mode: str = "corner"
|
|
font: str = "orbitron" # "orbitron" | "audiowide"
|
|
text_width_frac: float = 0.42 # target wordmark width as fraction of image W
|
|
margin_frac: float = 0.030 # gap from image edges
|
|
plate: bool = True # draw the semi-opaque black plate (corner only)
|
|
plate_alpha: int = 140 # 0-255 plate opacity (~55%)
|
|
plate_pad_frac: float = 0.45 # plate padding, as fraction of cap height
|
|
glow: bool = True # neon pink outer glow
|
|
stroke_frac: float = 0.085 # stroke width as fraction of cap height
|
|
fill_alpha: int = 235 # wordmark opacity
|
|
position: str = "bottom-right" # "bottom-right" | "bottom-center" (corner mode)
|
|
# crop-resistant params
|
|
angle: float = 28.0 # rotation for band/tile modes (degrees CCW)
|
|
tile_gap_frac: float = 0.55 # tile spacing as fraction of cell size
|
|
overlay_alpha: int = 255 # final opacity of the whole overlay (0-255)
|
|
rail_side: str = "auto" # edge-rail side: "auto" | "left" | "right"
|
|
rail_width_frac: float = 0.50 # rail wordmark length as fraction of image H
|
|
|
|
|
|
def _load_font(style: WatermarkStyle, px: int) -> ImageFont.FreeTypeFont:
|
|
if style.font == "audiowide":
|
|
return ImageFont.truetype(os.path.join(FONT_DIR, "Audiowide-Regular.ttf"), px)
|
|
f = ImageFont.truetype(os.path.join(FONT_DIR, "Orbitron[wght].ttf"), px)
|
|
try:
|
|
f.set_variation_by_axes([800]) # heavy weight reads better small
|
|
except Exception:
|
|
pass
|
|
return f
|
|
|
|
|
|
def _measure(font: ImageFont.FreeTypeFont, text: str) -> tuple[int, int, int, int]:
|
|
# (left, top, right, bottom) tight bbox with stroke excluded
|
|
return ImageDraw.Draw(Image.new("RGB", (4, 4))).textbbox((0, 0), text, font=font)
|
|
|
|
|
|
def _fit_font(style: WatermarkStyle, target_w: int) -> tuple[ImageFont.FreeTypeFont, tuple[int, int, int, int]]:
|
|
"""Binary-search a pixel size so the wordmark width ~= target_w."""
|
|
lo, hi = 6, 4000
|
|
best = _load_font(style, lo)
|
|
best_bbox = _measure(best, WATERMARK_TEXT)
|
|
for _ in range(28):
|
|
mid = (lo + hi) // 2
|
|
f = _load_font(style, mid)
|
|
l, t, r, b = _measure(f, WATERMARK_TEXT)
|
|
w = r - l
|
|
if w <= target_w:
|
|
best, best_bbox = f, (l, t, r, b)
|
|
lo = mid + 1
|
|
else:
|
|
hi = mid - 1
|
|
return best, best_bbox
|
|
|
|
|
|
def _stamp(style: WatermarkStyle, target_w: int) -> Image.Image:
|
|
"""Render one wordmark (glow + stroke + pink fill) onto a tight RGBA tile."""
|
|
font, (bl, bt, br, bb) = _fit_font(style, target_w)
|
|
text_w, text_h = br - bl, bb - bt
|
|
cap = text_h
|
|
stroke = max(1, round(cap * style.stroke_frac))
|
|
blur = max(2, round(cap * 0.14)) if style.glow else 0
|
|
pad = stroke + blur * 2 + 2
|
|
W = text_w + 2 * pad
|
|
H = text_h + 2 * pad
|
|
tx, ty = pad - bl, pad - bt
|
|
|
|
tile = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
if style.glow:
|
|
gd = ImageDraw.Draw(tile)
|
|
gd.text((tx, ty), WATERMARK_TEXT, font=font, fill=(*PINK_GLOW, 255),
|
|
stroke_width=stroke, stroke_fill=(*PINK_GLOW, 255))
|
|
tile = tile.filter(ImageFilter.GaussianBlur(blur))
|
|
ImageDraw.Draw(tile).text((tx, ty), WATERMARK_TEXT, font=font,
|
|
fill=(*PINK, style.fill_alpha),
|
|
stroke_width=stroke, stroke_fill=(*BLACK, 255))
|
|
return tile
|
|
|
|
|
|
def _stamp_plated(style: WatermarkStyle, target_w: int) -> Image.Image:
|
|
"""A wordmark stamp with a rounded semi-opaque black plate behind it."""
|
|
mark = _stamp(style, target_w)
|
|
if not style.plate:
|
|
return mark
|
|
cap = mark.height
|
|
pad = round(cap * style.plate_pad_frac)
|
|
W, H = mark.width + 2 * pad, mark.height + 2 * pad
|
|
out = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
ImageDraw.Draw(out).rounded_rectangle(
|
|
[0, 0, W, H], radius=round(cap * 0.30), fill=(*BLACK, style.plate_alpha))
|
|
out.alpha_composite(mark, (pad, pad))
|
|
return out
|
|
|
|
|
|
def _edge_busyness(base: Image.Image) -> tuple[float, float]:
|
|
"""Mean edge energy in the left vs right 16% columns (mid 60% height)."""
|
|
g = base.convert("L").filter(ImageFilter.FIND_EDGES)
|
|
W, H = g.size
|
|
cw = int(W * 0.16)
|
|
band = (int(H * 0.2), int(H * 0.8))
|
|
left = g.crop((0, band[0], cw, band[1]))
|
|
right = g.crop((W - cw, band[0], W, band[1]))
|
|
lvals = list(left.getdata())
|
|
rvals = list(right.getdata())
|
|
return sum(lvals) / len(lvals), sum(rvals) / len(rvals)
|
|
|
|
|
|
def _render_edge_rail(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
stamp = _stamp_plated(style, int(H * style.rail_width_frac))
|
|
side = style.rail_side
|
|
if side == "auto":
|
|
lbusy, rbusy = _edge_busyness(base)
|
|
side = "left" if lbusy < rbusy else "right"
|
|
margin = round(W * style.margin_frac)
|
|
if side == "right":
|
|
rail = stamp.rotate(-90, expand=True) # reads bottom->top
|
|
x = W - margin - rail.width
|
|
else:
|
|
rail = stamp.rotate(90, expand=True) # reads top->bottom
|
|
x = margin
|
|
y = (H - rail.height) // 2
|
|
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
overlay.alpha_composite(rail, (x, y))
|
|
return overlay
|
|
|
|
|
|
def _render_dual_corner(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
stamp = _stamp_plated(style, int(W * style.text_width_frac))
|
|
margin = round(W * style.margin_frac)
|
|
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
overlay.alpha_composite(stamp, (margin, margin)) # top-left
|
|
overlay.alpha_composite(stamp, (W - margin - stamp.width, H - margin - stamp.height)) # bottom-right
|
|
return overlay
|
|
|
|
|
|
def _render_corner(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
font, (bl, bt, br, bb) = _fit_font(style, int(W * style.text_width_frac))
|
|
text_w, text_h = br - bl, bb - bt
|
|
cap = text_h
|
|
stroke = max(1, round(cap * style.stroke_frac))
|
|
margin = round(W * style.margin_frac)
|
|
pad = round(cap * style.plate_pad_frac)
|
|
block_w = text_w + 2 * stroke + 2 * pad
|
|
block_h = text_h + 2 * stroke + 2 * pad
|
|
bx = (W - block_w) // 2 if style.position == "bottom-center" else W - margin - block_w
|
|
by = H - margin - block_h
|
|
|
|
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
if style.plate:
|
|
draw.rounded_rectangle([bx, by, bx + block_w, by + block_h],
|
|
radius=round(cap * 0.42), fill=(*BLACK, style.plate_alpha))
|
|
tx, ty = bx + pad + stroke - bl, by + pad + stroke - bt
|
|
if style.glow:
|
|
glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
ImageDraw.Draw(glow).text((tx, ty), WATERMARK_TEXT, font=font, fill=(*PINK_GLOW, 255),
|
|
stroke_width=stroke, stroke_fill=(*PINK_GLOW, 255))
|
|
overlay = Image.alpha_composite(overlay, glow.filter(ImageFilter.GaussianBlur(max(2, round(cap * 0.14)))))
|
|
draw = ImageDraw.Draw(overlay)
|
|
draw.text((tx, ty), WATERMARK_TEXT, font=font, fill=(*PINK, style.fill_alpha),
|
|
stroke_width=stroke, stroke_fill=(*BLACK, 255))
|
|
return overlay
|
|
|
|
|
|
def _render_center(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
stamp = _stamp(style, int(W * style.text_width_frac))
|
|
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
overlay.alpha_composite(stamp, ((W - stamp.width) // 2, (H - stamp.height) // 2))
|
|
return overlay
|
|
|
|
|
|
def _render_band(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
stamp = _stamp(style, int(W * style.text_width_frac))
|
|
rot = stamp.rotate(style.angle, expand=True, resample=Image.BICUBIC)
|
|
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
overlay.alpha_composite(rot, ((W - rot.width) // 2, (H - rot.height) // 2))
|
|
return overlay
|
|
|
|
|
|
def _render_tile(base: Image.Image, style: WatermarkStyle) -> Image.Image:
|
|
W, H = base.size
|
|
cell = _stamp(style, int(W * style.text_width_frac))
|
|
gx = round(cell.width * (1 + style.tile_gap_frac))
|
|
gy = round(cell.height * (1 + style.tile_gap_frac) * 2.2)
|
|
# Build an oversized flat grid, then rotate the whole grid for clean diagonals.
|
|
diag = int((W ** 2 + H ** 2) ** 0.5) + max(gx, gy)
|
|
grid = Image.new("RGBA", (diag, diag), (0, 0, 0, 0))
|
|
row = 0
|
|
for y in range(-cell.height, diag, gy):
|
|
offset = (gx // 2) if (row % 2) else 0
|
|
for x in range(-cell.width, diag, gx):
|
|
grid.alpha_composite(cell, (x + offset, y))
|
|
row += 1
|
|
grid = grid.rotate(style.angle, resample=Image.BICUBIC)
|
|
overlay = grid.crop(((diag - W) // 2, (diag - H) // 2, (diag - W) // 2 + W, (diag - H) // 2 + H))
|
|
return overlay
|
|
|
|
|
|
def render_watermark(img: Image.Image, style: WatermarkStyle = WatermarkStyle()) -> Image.Image:
|
|
"""Return a new RGB image with the watermark composited on. Input untouched."""
|
|
base = img.convert("RGBA")
|
|
renderer = {
|
|
"corner": _render_corner,
|
|
"center": _render_center,
|
|
"diagonal-band": _render_band,
|
|
"diagonal-tile": _render_tile,
|
|
"edge-rail": _render_edge_rail,
|
|
"dual-corner": _render_dual_corner,
|
|
}.get(style.mode)
|
|
if renderer is None:
|
|
raise ValueError(f"unknown watermark mode {style.mode!r}")
|
|
overlay = renderer(base, style)
|
|
if style.overlay_alpha < 255:
|
|
a = overlay.getchannel("A").point(lambda v: v * style.overlay_alpha // 255)
|
|
overlay.putalpha(a)
|
|
out = Image.alpha_composite(base, overlay)
|
|
return out.convert("RGB")
|