Emp-post-studio / src /generate_posts.py
Teststrelema's picture
update code wle , ann, optimaztion
d8adc3d
Raw History Blame Contribute Delete
21.6 kB
import argparse
import csv
import json
import os
from collections import deque
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Dict, Tuple
import requests
import numpy as np
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
# rembg and cv2 are loaded lazily inside remove_background_rembg() the first
# time background removal is actually requested, so app startup is not penalised
# when every template has remove_background=false (the common case).
_rembg_remove = None # module-level cache after first lazy load
_cv2 = None # module-level cache after first lazy load
def load_config(config_path: Path) -> Dict:
with config_path.open("r", encoding="utf-8-sig") as f:
return json.load(f)
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def get_font(font_path: Path, size: int) -> ImageFont.FreeTypeFont:
if font_path.exists():
return ImageFont.truetype(str(font_path), size=size)
try:
# Use a scalable fallback on Linux/HF so text size still respects `size`.
return ImageFont.truetype("DejaVuSans-Bold.ttf", size=size)
except Exception:
return ImageFont.load_default()
def make_default_template(path: Path, occasion: str, canvas: Dict) -> None:
ensure_dir(path.parent)
img = Image.new("RGB", (canvas["width"], canvas["height"]), canvas.get("background_color", "#ffffff"))
draw = ImageDraw.Draw(img)
# Simple fallback visual so the pipeline works before final branded templates are added.
header_color = "#ff8a65" if occasion == "birthday" else "#4fc3f7"
draw.rectangle([(0, 0), (canvas["width"], 200)], fill=header_color)
draw.text((40, 60), f"{occasion.upper()} POST TEMPLATE", fill="white", font=ImageFont.load_default())
draw.rectangle([(340, 230), (740, 630)], outline="#777777", width=6)
draw.text((460, 420), "PHOTO", fill="#777777", font=ImageFont.load_default())
draw.rectangle([(120, 860), (960, 980)], outline="#777777", width=4)
draw.text((430, 910), "NAME", fill="#777777", font=ImageFont.load_default())
img.save(path)
def load_photo(photo_ref: str) -> Image.Image:
photo_ref = (photo_ref or "").strip().strip('"').strip("'")
if photo_ref.lower().startswith(("http://", "https://")):
response = requests.get(photo_ref, timeout=20)
response.raise_for_status()
return Image.open(BytesIO(response.content)).convert("RGBA")
local_path = Path(photo_ref)
if not local_path.is_absolute():
local_path = Path.cwd() / local_path
if not local_path.exists():
raise FileNotFoundError(f"Photo not found: {local_path}")
return Image.open(local_path).convert("RGBA")
def cover_resize(
image: Image.Image,
target_w: int,
target_h: int,
focus_x: float = 0.5,
focus_y: float = 0.5,
) -> Image.Image:
src_w, src_h = image.size
src_ratio = src_w / src_h
target_ratio = target_w / target_h
if src_ratio > target_ratio:
# Crop width with configurable focal point.
new_w = int(src_h * target_ratio)
free_x = max(0, src_w - new_w)
left = int(round(free_x * max(0.0, min(1.0, focus_x))))
image = image.crop((left, 0, left + new_w, src_h))
else:
# Crop height with configurable focal point.
new_h = int(src_w / target_ratio)
free_y = max(0, src_h - new_h)
top = int(round(free_y * max(0.0, min(1.0, focus_y))))
image = image.crop((0, top, src_w, top + new_h))
return image.resize((target_w, target_h), Image.Resampling.LANCZOS)
def contain_resize(image: Image.Image, target_w: int, target_h: int) -> Image.Image:
src_w, src_h = image.size
scale = min(target_w / src_w, target_h / src_h)
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
return image.resize((new_w, new_h), Image.Resampling.LANCZOS)
def trim_transparent_bounds(image: Image.Image) -> Image.Image:
if image.mode != "RGBA":
return image
alpha = image.getchannel("A")
bbox = alpha.getbbox()
if not bbox:
return image
return image.crop(bbox)
def remove_background_from_borders(photo: Image.Image, tolerance: int = 42) -> Image.Image:
arr = np.array(photo.convert("RGBA"))
h, w, _ = arr.shape
rgb = arr[:, :, :3]
hsv = np.array(Image.fromarray(rgb, mode="RGB").convert("HSV"))
hch = hsv[:, :, 0].astype(np.int16)
sch = hsv[:, :, 1].astype(np.int16)
vch = hsv[:, :, 2].astype(np.int16)
# Red-wall background key (tight range to avoid removing skin tones).
red_hue = (hch <= 12) | (hch >= 245)
strong_sat = sch >= 95
bright_enough = vch >= 45
similar = red_hue & strong_sat & bright_enough
visited = np.zeros((h, w), dtype=np.uint8)
q = deque()
for x in range(w):
if similar[0, x]:
q.append((0, x))
if similar[h - 1, x]:
q.append((h - 1, x))
for y in range(h):
if similar[y, 0]:
q.append((y, 0))
if similar[y, w - 1]:
q.append((y, w - 1))
while q:
y, x = q.popleft()
if visited[y, x] or (not similar[y, x]):
continue
visited[y, x] = 1
if y > 0 and not visited[y - 1, x]:
q.append((y - 1, x))
if y < h - 1 and not visited[y + 1, x]:
q.append((y + 1, x))
if x > 0 and not visited[y, x - 1]:
q.append((y, x - 1))
if x < w - 1 and not visited[y, x + 1]:
q.append((y, x + 1))
alpha = arr[:, :, 3].copy()
alpha[visited == 1] = 0
out = Image.fromarray(np.dstack([arr[:, :, :3], alpha]).astype(np.uint8), mode="RGBA")
out.putalpha(out.split()[3].filter(ImageFilter.GaussianBlur(radius=0.8)))
return out
def remove_background_grabcut(photo: Image.Image) -> Image.Image:
if cv2 is None:
return photo
rgb = np.array(photo.convert("RGB"))
h, w = rgb.shape[:2]
if h < 8 or w < 8:
return photo
mask = np.zeros((h, w), np.uint8)
bgd_model = np.zeros((1, 65), np.float64)
fgd_model = np.zeros((1, 65), np.float64)
rect = (max(1, int(w * 0.03)), max(1, int(h * 0.03)), max(2, int(w * 0.94)), max(2, int(h * 0.94)))
cv2.grabCut(rgb, mask, rect, bgd_model, fgd_model, 6, cv2.GC_INIT_WITH_RECT)
alpha = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
alpha = cv2.GaussianBlur(alpha, (0, 0), 1.2)
rgba = np.dstack([rgb, alpha])
return Image.fromarray(rgba, mode="RGBA")
def remove_background_rembg(photo: Image.Image) -> Image.Image:
# Lazy-load rembg and cv2 on first use so app startup stays fast when
# remove_background=false (no import penalty at module load time).
global _rembg_remove, _cv2
if _rembg_remove is None:
try:
from rembg import remove as _fn
_rembg_remove = _fn
except Exception:
_rembg_remove = False # sentinel: tried and failed
if _rembg_remove is False:
return photo
if _cv2 is None:
try:
import cv2 as _cv2_mod
_cv2 = _cv2_mod
except Exception:
_cv2 = False # sentinel: tried and failed
out = _rembg_remove(photo.convert("RGBA")).convert("RGBA")
if _cv2 is False:
return out
arr = np.array(out)
alpha = arr[:, :, 3]
# Binarize alpha and keep only the largest connected foreground region.
_, bin_alpha = _cv2.threshold(alpha, 16, 255, _cv2.THRESH_BINARY)
num_labels, labels, stats, _ = _cv2.connectedComponentsWithStats((bin_alpha > 0).astype(np.uint8), 8)
if num_labels > 1:
# Skip background label 0.
largest = 1 + np.argmax(stats[1:, _cv2.CC_STAT_AREA])
clean = np.where(labels == largest, 255, 0).astype(np.uint8)
else:
clean = bin_alpha
# Smooth edge and close pinholes.
kernel = np.ones((3, 3), np.uint8)
clean = _cv2.morphologyEx(clean, _cv2.MORPH_CLOSE, kernel, iterations=1)
clean = _cv2.GaussianBlur(clean, (0, 0), 0.8)
arr[:, :, 3] = clean
return Image.fromarray(arr, mode="RGBA")
def maybe_extract_subject(photo: Image.Image, template_size: Tuple[int, int], tcfg: Dict) -> Image.Image:
mode = tcfg.get("source_mode", "auto")
if mode in ("auto", "full_post"):
pw, ph = photo.size
tw, th = template_size
same_ratio = abs((pw / ph) - (tw / th)) < 0.01
similar_size = abs(pw - tw) <= max(10, int(tw * 0.05)) and abs(ph - th) <= max(10, int(th * 0.05))
if mode == "full_post" or (mode == "auto" and same_ratio and similar_size):
# For full composed poster inputs, extract the lower-left subject zone.
box = tcfg.get("source_crop_box", {"x": 80, "y": 580, "width": 970, "height": 1260})
x1 = max(0, int(box["x"]))
y1 = max(0, int(box["y"]))
x2 = min(pw, x1 + int(box["width"]))
y2 = min(ph, y1 + int(box["height"]))
photo = photo.crop((x1, y1, x2, y2))
if tcfg.get("remove_background", False):
method = tcfg.get("bg_method", "rembg")
if method == "rembg":
photo = remove_background_rembg(photo)
elif method == "grabcut":
photo = remove_background_grabcut(photo)
else:
photo = remove_background_from_borders(photo, int(tcfg.get("bg_tolerance", 45)))
return photo
def paste_photo(
base: Image.Image,
person: Image.Image,
photo_box: Dict,
shape: str = "rectangle",
fit_mode: str = "cover",
anchor: str = "center",
) -> None:
person = trim_transparent_bounds(person)
top_pct = float(photo_box.get("photo_top_percent", 1.0))
if top_pct < 1.0:
pw, ph = person.size
person = person.crop((0, 0, pw, max(1, int(ph * top_pct))))
bw = int(photo_box["width"])
bh = int(photo_box["height"])
scale = float(photo_box.get("photo_scale", 1.0))
scale = max(0.1, min(scale, 5.0))
target_w = max(1, int(round(bw * scale)))
target_h = max(1, int(round(bh * scale)))
focus_x = float(photo_box.get("focus_x", 0.5))
focus_y = float(photo_box.get("focus_y", 0.5))
resized = (
contain_resize(person, target_w, target_h)
if fit_mode == "contain"
else cover_resize(person, target_w, target_h, focus_x=focus_x, focus_y=focus_y)
)
x, y = int(photo_box["x"]), int(photo_box["y"])
if anchor == "bottom_center":
x = int(photo_box["x"]) + (bw - resized.width) // 2
y = int(photo_box["y"]) + (bh - resized.height)
elif anchor == "center":
x = int(photo_box["x"]) + (bw - resized.width) // 2
y = int(photo_box["y"]) + (bh - resized.height) // 2
elif anchor == "top":
x = int(photo_box["x"]) + (bw - resized.width) // 2
y = int(photo_box["y"])
x += int(photo_box.get("photo_offset_x", 0))
y += int(photo_box.get("photo_offset_y", 0))
# Compose into an explicit box layer so the result always matches the exact box size.
bg_hex = photo_box.get("photo_bg_color", "")
if bg_hex:
from PIL import ImageColor
r, g, b = ImageColor.getrgb(bg_hex)
box_layer = Image.new("RGBA", (bw, bh), (r, g, b, 255))
else:
box_layer = Image.new("RGBA", (bw, bh), (0, 0, 0, 0))
local_x = x - int(photo_box["x"])
local_y = y - int(photo_box["y"])
box_layer.paste(resized, (local_x, local_y), resized if resized.mode == "RGBA" else None)
if shape == "circle":
circle_mask = Image.new("L", (bw, bh), 0)
mdraw = ImageDraw.Draw(circle_mask)
mdraw.ellipse((0, 0, bw - 1, bh - 1), fill=255)
existing_alpha = box_layer.getchannel("A")
clipped_alpha = ImageChops.multiply(existing_alpha, circle_mask)
box_layer.putalpha(clipped_alpha)
elif shape == "diamond":
diamond_mask = Image.new("L", (bw, bh), 0)
mdraw = ImageDraw.Draw(diamond_mask)
cx, cy = bw // 2, bh // 2
mdraw.polygon([(cx, 0), (bw - 1, cy), (cx, bh - 1), (0, cy)], fill=255)
existing_alpha = box_layer.getchannel("A")
clipped_alpha = ImageChops.multiply(existing_alpha, diamond_mask)
box_layer.putalpha(clipped_alpha)
elif shape == "kite":
# Asymmetric diamond clipped to the photo box.
# kite_top_y / kite_bottom_y are the absolute y of the full diamond tips.
# The photo box (y, height) can start/end inside those tips so the
# narrow spike region is not covered by the photo (template shows there).
by_abs = int(photo_box["y"])
kcy_abs = int(photo_box.get("kite_center_y", by_abs + bh // 2))
top_tip = float(photo_box.get("kite_top_y", by_abs))
bot_tip = float(photo_box.get("kite_bottom_y", by_abs + bh))
ys_m, xs_m = np.mgrid[0:bh, 0:bw].astype(float)
y_abs_m = ys_m + by_abs
cx_m = (bw - 1) / 2.0
max_rx = (bw - 1) / 2.0
is_upper = y_abs_m <= kcy_abs
hw_upper = max_rx * np.maximum(0.0, y_abs_m - top_tip) / max(1.0, kcy_abs - top_tip)
hw_lower = max_rx * np.maximum(0.0, bot_tip - y_abs_m) / max(1.0, bot_tip - kcy_abs)
hw = np.where(is_upper, hw_upper, hw_lower)
kite_arr = np.where(np.abs(xs_m - cx_m) <= hw, 255, 0).astype(np.uint8)
kite_mask = Image.fromarray(kite_arr, mode="L")
existing_alpha = box_layer.getchannel("A")
clipped_alpha = ImageChops.multiply(existing_alpha, kite_mask)
box_layer.putalpha(clipped_alpha)
elif shape == "rounded_rectangle":
radius = int(photo_box.get("corner_radius", min(bw, bh) // 6))
rr_mask = Image.new("L", (bw, bh), 0)
rr_draw = ImageDraw.Draw(rr_mask)
try:
rr_draw.rounded_rectangle([(0, 0), (bw - 1, bh - 1)], radius=radius, fill=255)
except AttributeError:
rr_draw.rectangle([(radius, 0), (bw - 1 - radius, bh - 1)], fill=255)
rr_draw.rectangle([(0, radius), (bw - 1, bh - 1 - radius)], fill=255)
for cx, cy in [(radius, radius), (bw - 1 - radius, radius), (radius, bh - 1 - radius), (bw - 1 - radius, bh - 1 - radius)]:
rr_draw.ellipse([(cx - radius, cy - radius), (cx + radius, cy + radius)], fill=255)
existing_alpha = box_layer.getchannel("A")
clipped_alpha = ImageChops.multiply(existing_alpha, rr_mask)
box_layer.putalpha(clipped_alpha)
elif shape == "rounded_diamond":
r = int(photo_box.get("corner_radius", min(bw, bh) // 8))
cx_d, cy_d = bw // 2, bh // 2
rd_mask = Image.new("L", (bw, bh), 0)
rd_draw = ImageDraw.Draw(rd_mask)
# Full sharp diamond
rd_draw.polygon([(cx_d, 0), (bw - 1, cy_d), (cx_d, bh - 1), (0, cy_d)], fill=255)
# Replace each sharp tip with a rounded cap (blank triangle → draw ellipse)
rd_draw.polygon([(cx_d, 0), (cx_d + r, r), (cx_d - r, r)], fill=0)
rd_draw.ellipse([(cx_d - r, 0), (cx_d + r, 2 * r)], fill=255)
rd_draw.polygon([(bw - 1, cy_d), (bw - 1 - r, cy_d - r), (bw - 1 - r, cy_d + r)], fill=0)
rd_draw.ellipse([(bw - 1 - 2 * r, cy_d - r), (bw - 1, cy_d + r)], fill=255)
rd_draw.polygon([(cx_d, bh - 1), (cx_d + r, bh - 1 - r), (cx_d - r, bh - 1 - r)], fill=0)
rd_draw.ellipse([(cx_d - r, bh - 1 - 2 * r), (cx_d + r, bh - 1)], fill=255)
rd_draw.polygon([(0, cy_d), (r, cy_d - r), (r, cy_d + r)], fill=0)
rd_draw.ellipse([(0, cy_d - r), (2 * r, cy_d + r)], fill=255)
existing_alpha = box_layer.getchannel("A")
clipped_alpha = ImageChops.multiply(existing_alpha, rd_mask)
box_layer.putalpha(clipped_alpha)
base.paste(box_layer, (int(photo_box["x"]), int(photo_box["y"])), box_layer)
def fit_name_font(draw: ImageDraw.ImageDraw, name: str, box: Dict, font_path: Path, max_size: int, min_size: int):
for size in range(max_size, min_size - 1, -2):
font = get_font(font_path, size)
bbox = draw.textbbox((0, 0), name, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
if text_w <= box["width"] and text_h <= box["height"]:
return font, text_w, text_h
font = get_font(font_path, min_size)
bbox = draw.textbbox((0, 0), name, font=font)
return font, bbox[2] - bbox[0], bbox[3] - bbox[1]
def paste_logo(base: Image.Image, tcfg: Dict) -> None:
logo_path_raw = (tcfg.get("logo_path") or "").strip()
logo_box = tcfg.get("logo_box")
if not logo_path_raw or not logo_box:
return
logo_path = Path(logo_path_raw)
if not logo_path.is_absolute():
logo_path = Path.cwd() / logo_path
if not logo_path.exists():
return
logo = Image.open(logo_path).convert("RGBA")
bw = max(1, int(logo_box["width"]))
bh = max(1, int(logo_box["height"]))
fitted = contain_resize(logo, bw, bh)
layer = Image.new("RGBA", (bw, bh), (0, 0, 0, 0))
ox = (bw - fitted.width) // 2
oy = (bh - fitted.height) // 2
layer.paste(fitted, (ox, oy), fitted)
base.paste(layer, (int(logo_box["x"]), int(logo_box["y"])), layer)
def sanitize_filename(value: str) -> str:
keep = []
for ch in value:
if ch.isalnum() or ch in ("-", "_"):
keep.append(ch)
elif ch.isspace():
keep.append("_")
cleaned = "".join(keep).strip("_")
return cleaned or "post"
def generate_one_post(row: Dict, config: Dict, output_root: Path) -> Tuple[bool, str]:
occasion = (row.get("occasion") or "").strip().lower()
name = (row.get("employee_name") or "").strip()
photo_ref = (row.get("photo_path") or "").strip()
date_str = (row.get("date") or datetime.now().strftime("%Y-%m-%d")).strip()
if occasion not in config["templates"]:
return False, f"Invalid occasion '{occasion}'"
if not name:
return False, "Missing employee_name"
if not photo_ref:
return False, f"Missing photo_path for '{name}'"
tcfg = config["templates"][occasion]
template_path = Path(tcfg["path"])
if not template_path.is_absolute():
template_path = Path.cwd() / template_path
if not template_path.exists():
make_default_template(template_path, occasion, config["canvas"])
base = Image.open(template_path).convert("RGBA")
paste_logo(base, tcfg)
photo_box = dict(tcfg["photo_box"]) # shallow copy so row override doesn't mutate config
row_top_pct = (row.get("photo_top_percent") or "").strip()
if row_top_pct:
photo_box["photo_top_percent"] = float(row_top_pct)
person = load_photo(photo_ref)
person = maybe_extract_subject(person, base.size, tcfg)
paste_photo(
base,
person,
photo_box,
tcfg.get("photo_shape", "rectangle"),
tcfg.get("photo_fit", "cover"),
tcfg.get("photo_anchor", "center"),
)
draw = ImageDraw.Draw(base)
font_path = Path(tcfg.get("name_font_path", config.get("default_font_path", "")))
if not font_path.is_absolute():
font_path = Path.cwd() / font_path
name_box = tcfg["name_box"]
render_name = name.upper() if tcfg.get("name_uppercase", False) else name
font, text_w, text_h = fit_name_font(
draw,
render_name,
name_box,
font_path,
int(tcfg.get("max_font_size", 72)),
int(tcfg.get("min_font_size", 32)),
)
align = tcfg.get("text_align", "center")
if align == "left":
tx = name_box["x"]
elif align == "right":
tx = name_box["x"] + name_box["width"] - text_w
else:
tx = name_box["x"] + (name_box["width"] - text_w) // 2
ty = name_box["y"] + (name_box["height"] - text_h) // 2
draw.text((tx, ty), render_name, fill=tcfg.get("name_color", "#111111"), font=font)
out_dir = output_root / date_str
ensure_dir(out_dir)
safe_name = sanitize_filename(name)
out_file = out_dir / f"{occasion}_{safe_name}.png"
base.save(out_file, format=config["output"].get("image_format", "PNG"))
return True, str(out_file)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate birthday/welcome posts from CSV input.")
parser.add_argument("--config", default="config.json", help="Path to config JSON")
parser.add_argument("--input", default="input/posts.csv", help="Path to CSV input")
args = parser.parse_args()
config = load_config(Path(args.config))
output_dir = Path(config["output"].get("directory", "output"))
if not output_dir.is_absolute():
output_dir = Path.cwd() / output_dir
ensure_dir(output_dir)
input_path = Path(args.input)
if not input_path.is_absolute():
input_path = Path.cwd() / input_path
if not input_path.exists():
raise FileNotFoundError(f"Input CSV not found: {input_path}")
success = 0
failed = 0
with input_path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader, start=2):
try:
ok, message = generate_one_post(row, config, output_dir)
if ok:
success += 1
print(f"[OK][line {i}] {message}")
else:
failed += 1
print(f"[FAIL][line {i}] {message}")
except Exception as exc:
failed += 1
print(f"[ERROR][line {i}] {exc}")
print("-" * 50)
print(f"Completed. Success: {success}, Failed: {failed}")
if __name__ == "__main__":
main()