Files
Templated-PPT-Master/runtime/ppt-master/scripts/slice_images.py
T

344 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
PPT Master - Illustration Sheet Slicer
Slice one AI-generated "illustration sheet" (a single image whose prompt laid
out several illustration elements in a grid) into N individual element files in
the project's `images/` folder. This is the cheap-and-consistent path for spot
illustrations: generate one multi-element sheet with `image_gen.py` (one call,
one coherent style/palette), then cut the cells out here so each element is a
normal image the Executor places like any other.
Two optional cleanups address the realities of cropping a raster sheet:
--trim tight-crop each cell to its content bounding box, so imprecise AI
placement inside a cell does not leave lopsided margins.
--alpha knock the (flat) sheet background out to transparency, so an element
can sit on a differently-colored slide without a visible box.
Both need a background color; it is auto-sampled from each cell's border unless
you pass --bg.
Usage:
python3 scripts/slice_images.py <sheet_image> --grid RxC [options]
Examples:
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \
--names team,product,customer,growth,risk,vision --trim --alpha
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 1x4 \
--prefix spot_ --bg "#F8F9FA" --alpha
Dependencies:
Pillow
"""
import argparse
import re
import sys
from pathlib import Path
from statistics import median
from typing import Optional
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
from PIL import Image, ImageChops, ImageFilter
_GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$")
_BG_SAMPLE_BORDER = 2
_DEFAULT_FEATHER = 4
def _log(msg: str) -> None:
"""Print progress to stderr (stdout carries the created file paths)."""
print(msg, file=sys.stderr)
def parse_grid(spec: str) -> tuple[int, int]:
"""Parse a 'RxC' grid spec into (rows, cols)."""
m = _GRID_RE.match(spec)
if not m:
raise ValueError(f"--grid must look like '2x3' (rows x cols), got {spec!r}")
rows, cols = int(m.group(1)), int(m.group(2))
if rows < 1 or cols < 1:
raise ValueError(f"--grid rows and cols must be >= 1, got {rows}x{cols}")
return rows, cols
def parse_hex(value: str) -> tuple[int, int, int]:
"""Parse '#RRGGBB' / 'RRGGBB' into an (r, g, b) tuple."""
h = value.strip().lstrip("#")
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
raise ValueError(f"--bg must be a 6-digit hex color, got {value!r}")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _safe_basename(name: str) -> str:
"""Reject path components in an output name — this tool writes bare files only."""
base = name.strip()
if (not base or base in {".", ".."} or ".." in base
or "/" in base or "\\" in base or Path(base).is_absolute()):
raise ValueError(f"unsafe output name {name!r}: must be a bare filename, no path parts")
return base
def _sample_bg(cell: Image.Image) -> tuple[int, int, int]:
"""Estimate the flat background color from a cell's border ring."""
rgb = cell.convert("RGB")
w, h = rgb.size
border = max(1, min(_BG_SAMPLE_BORDER, w, h))
px = rgb.load()
pixels = []
for y in range(border):
for x in range(w):
pixels.append(px[x, y])
bottom_start = max(border, h - border)
for y in range(bottom_start, h):
for x in range(w):
pixels.append(px[x, y])
right_start = max(border, w - border)
for y in range(border, bottom_start):
for x in range(border):
pixels.append(px[x, y])
for x in range(right_start, w):
pixels.append(px[x, y])
return tuple(round(median(channel)) for channel in zip(*pixels)) # type: ignore[return-value]
def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image:
"""Return the maximum absolute RGB channel difference from the background."""
diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg))
red, green, blue = diff.split()
return ImageChops.lighter(ImageChops.lighter(red, green), blue)
def _soft_mask_from_diff(diff: Image.Image, tolerance: int) -> Image.Image:
"""Build a feathered alpha mask around the tolerance threshold."""
low = max(0, tolerance - _DEFAULT_FEATHER)
high = min(255, tolerance + _DEFAULT_FEATHER)
if high <= low:
return diff.point(lambda p: 255 if p > tolerance else 0)
span = high - low
lut = []
for value in range(256):
if value <= low:
lut.append(0)
elif value >= high:
lut.append(255)
else:
lut.append(round((value - low) * 255 / span))
return diff.point(lut)
def _content_masks(
cell: Image.Image,
bg: tuple[int, int, int],
tolerance: int,
) -> tuple[Image.Image, Image.Image]:
"""Build binary trim and soft alpha masks from the same color distance."""
diff = _max_channel_difference(cell, bg)
trim_mask = diff.point(lambda p: 255 if p > tolerance else 0)
alpha_mask = _soft_mask_from_diff(diff, tolerance)
alpha_mask = alpha_mask.filter(ImageFilter.MinFilter(3))
return trim_mask, alpha_mask
def slice_sheet(
sheet_path: Path,
rows: int,
cols: int,
output_dir: Path,
*,
names: Optional[list[str]] = None,
prefix: Optional[str] = None,
inset: float = 0.0,
trim: bool = False,
alpha: bool = False,
bg: Optional[tuple[int, int, int]] = None,
tolerance: int = 18,
) -> list[Path]:
"""Slice `sheet_path` into rows*cols element PNGs under `output_dir`.
Returns the list of written file paths (row-major order). When `names` is
given it must hold exactly rows*cols entries — a mismatch is an error so an
automated run never silently drops cells. Each name must be a bare filename.
"""
total_cells = rows * cols
if names is not None and len(names) != total_cells:
raise ValueError(
f"--names has {len(names)} entries but the {rows}x{cols} grid has "
f"{total_cells} cells; provide exactly one name per cell"
)
safe_names = [_safe_basename(n) for n in names] if names else None
if safe_names:
seen_outputs: set[str] = set()
for name in safe_names:
output_name = name if Path(name).suffix else f"{name}.png"
normalized_output = output_name.casefold()
if normalized_output in seen_outputs:
raise ValueError(
f"--names repeats output filename {output_name!r} "
"(case-insensitive)"
)
seen_outputs.add(normalized_output)
if alpha and safe_names:
for name in safe_names:
suffix = Path(name).suffix.lower()
if suffix and suffix != ".png":
raise ValueError(f"--alpha requires .png output names, got {name!r}")
sheet = Image.open(sheet_path).convert("RGBA")
sw, sh = sheet.size
output_dir.mkdir(parents=True, exist_ok=True)
stem = sheet_path.stem
name_prefix = _safe_basename(prefix) if prefix else f"{stem}_"
written: list[Path] = []
idx = 0
for r in range(rows):
for c in range(cols):
# Integer cell box via per-index rounding to avoid drift.
x0, x1 = round(c * sw / cols), round((c + 1) * sw / cols)
y0, y1 = round(r * sh / rows), round((r + 1) * sh / rows)
if inset > 0:
dx = round((x1 - x0) * inset)
dy = round((y1 - y0) * inset)
x0, x1, y0, y1 = x0 + dx, x1 - dx, y0 + dy, y1 - dy
cell = sheet.crop((x0, y0, x1, y1))
trim_mask: Optional[Image.Image] = None
alpha_mask: Optional[Image.Image] = None
bbox = None
if trim or alpha:
cell_bg = bg if bg is not None else _sample_bg(cell)
trim_mask, alpha_mask = _content_masks(cell, cell_bg, tolerance)
bbox = trim_mask.getbbox()
if bbox is None:
raise ValueError(f"cell ({r},{c}) is all background; no element was sliced")
if trim and trim_mask is not None and alpha_mask is not None and bbox is not None:
cell = cell.crop(bbox)
alpha_mask = alpha_mask.crop(bbox)
if alpha and alpha_mask is not None:
cell.putalpha(alpha_mask)
if safe_names:
out_name = safe_names[idx]
if not Path(out_name).suffix:
out_name += ".png"
else:
out_name = f"{name_prefix}{idx + 1:02d}.png"
out_path = output_dir / out_name
cell.save(out_path)
written.append(out_path)
_log(f"[OK] cell ({r},{c}) -> {out_path.name} ({cell.width}x{cell.height})")
idx += 1
if len(written) != total_cells:
raise ValueError(f"sliced {len(written)} elements but expected {total_cells}")
return written
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser."""
parser = argparse.ArgumentParser(
description="Slice an AI illustration sheet into individual element images.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Examples:
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3
python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \\
--names team,product,customer,growth,risk,vision --trim --alpha
""",
)
parser.add_argument("sheet", help="Path to the generated illustration sheet image")
parser.add_argument("--grid", required=True, help="Grid as 'RxC' (rows x cols), e.g. 2x3")
parser.add_argument(
"-o", "--output", default=None,
help="Output directory (default: the sheet's own directory)",
)
parser.add_argument(
"--names", default=None,
help="Comma-separated element names, row-major (extension optional). "
"Must provide exactly rows*cols bare filenames.",
)
parser.add_argument(
"--prefix", default=None,
help="Filename prefix when --names is absent (default: '<sheet-stem>_')",
)
parser.add_argument(
"--inset", type=float, default=0.0,
help="Trim each cell inward by this fraction on every side (0-0.49) to drop gutters",
)
parser.add_argument(
"--trim", action="store_true",
help="Tight-crop each cell to its content bounding box",
)
parser.add_argument(
"--alpha", action="store_true",
help="Make the (flat) background transparent in each element",
)
parser.add_argument(
"--bg", default=None,
help="Background hex color for --trim/--alpha (default: auto-sample cell border)",
)
parser.add_argument(
"--tolerance", type=int, default=18,
help="Maximum per-channel color distance treated as background for --trim/--alpha "
"(default: 18)",
)
return parser
def main(argv: Optional[list[str]] = None) -> int:
"""Run the CLI entry point."""
parser = build_parser()
args = parser.parse_args(argv)
sheet_path = Path(args.sheet)
if not sheet_path.exists():
print(f"[ERROR] Sheet not found: {sheet_path}", file=sys.stderr)
return 1
try:
rows, cols = parse_grid(args.grid)
bg = parse_hex(args.bg) if args.bg else None
except ValueError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
if not 0.0 <= args.inset < 0.5:
print("[ERROR] --inset must be in [0, 0.5)", file=sys.stderr)
return 1
if not 0 <= args.tolerance <= 255:
print("[ERROR] --tolerance must be in [0, 255]", file=sys.stderr)
return 1
names = [n.strip() for n in args.names.split(",") if n.strip()] if args.names else None
output_dir = Path(args.output) if args.output else sheet_path.parent
try:
written = slice_sheet(
sheet_path, rows, cols, output_dir,
names=names, prefix=args.prefix, inset=args.inset,
trim=args.trim, alpha=args.alpha, bg=bg, tolerance=args.tolerance,
)
except (OSError, ValueError) as exc:
print(f"[ERROR] Slicing failed: {exc}", file=sys.stderr)
return 1
_log(f"\n[DONE] Wrote {len(written)} element(s) to {output_dir}")
for p in written:
print(p)
return 0
if __name__ == "__main__":
raise SystemExit(main())