Spaces:
Running
Running
Download src/generate_posts.py from Thestrelema/Emp-post-studio: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/spaces/Thestrelema/Emp-post-studio/resolve/main/src/generate_posts.py
- Command line
-
hf download hf://spaces/Thestrelema/Emp-post-studio/src/generate_posts.py
-
curl -L -o generate_posts.py https://huggingface.co/spaces/Thestrelema/Emp-post-studio/resolve/main/src/generate_posts.py
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() | |