| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Illustration Sheet Slicer |
| 4 | |
| 5 | Slice one AI-generated "illustration sheet" (a single image whose prompt laid |
| 6 | out several illustration elements in a grid) into N individual element files in |
| 7 | the project's `images/` folder. This is the cheap-and-consistent path for spot |
| 8 | illustrations: generate one multi-element sheet with `image_gen.py` (one call, |
| 9 | one coherent style/palette), then cut the cells out here so each element is a |
| 10 | normal image the Executor places like any other. |
| 11 | |
| 12 | Two optional cleanups address the realities of cropping a raster sheet: |
| 13 | --trim tight-crop each cell to its content bounding box, so imprecise AI |
| 14 | placement inside a cell does not leave lopsided margins. |
| 15 | --alpha knock the (flat) sheet background out to transparency, so an element |
| 16 | can sit on a differently-colored slide without a visible box. |
| 17 | Both need a background color; it is auto-sampled from each cell's border unless |
| 18 | you pass --bg. |
| 19 | |
| 20 | Usage: |
| 21 | python3 scripts/slice_images.py <sheet_image> --grid RxC [options] |
| 22 | |
| 23 | Examples: |
| 24 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 |
| 25 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \ |
| 26 | --names team,product,customer,growth,risk,vision --trim --alpha |
| 27 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 1x4 \ |
| 28 | --prefix spot_ --bg "#F8F9FA" --alpha |
| 29 | |
| 30 | Dependencies: |
| 31 | Pillow |
| 32 | """ |
| 33 | |
| 34 | import argparse |
| 35 | import re |
| 36 | import sys |
| 37 | from pathlib import Path |
| 38 | from statistics import median |
| 39 | from typing import Optional |
| 40 | |
| 41 | from console_encoding import configure_utf8_stdio |
| 42 | |
| 43 | configure_utf8_stdio() |
| 44 | |
| 45 | from PIL import Image, ImageChops, ImageFilter |
| 46 | |
| 47 | _GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$") |
| 48 | _BG_SAMPLE_BORDER = 2 |
| 49 | _DEFAULT_FEATHER = 4 |
| 50 | |
| 51 | |
| 52 | def _log(msg: str) -> None: |
| 53 | """Print progress to stderr (stdout carries the created file paths).""" |
| 54 | print(msg, file=sys.stderr) |
| 55 | |
| 56 | |
| 57 | def parse_grid(spec: str) -> tuple[int, int]: |
| 58 | """Parse a 'RxC' grid spec into (rows, cols).""" |
| 59 | m = _GRID_RE.match(spec) |
| 60 | if not m: |
| 61 | raise ValueError(f"--grid must look like '2x3' (rows x cols), got {spec!r}") |
| 62 | rows, cols = int(m.group(1)), int(m.group(2)) |
| 63 | if rows < 1 or cols < 1: |
| 64 | raise ValueError(f"--grid rows and cols must be >= 1, got {rows}x{cols}") |
| 65 | return rows, cols |
| 66 | |
| 67 | |
| 68 | def parse_hex(value: str) -> tuple[int, int, int]: |
| 69 | """Parse '#RRGGBB' / 'RRGGBB' into an (r, g, b) tuple.""" |
| 70 | h = value.strip().lstrip("#") |
| 71 | if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h): |
| 72 | raise ValueError(f"--bg must be a 6-digit hex color, got {value!r}") |
| 73 | return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) |
| 74 | |
| 75 | |
| 76 | def _safe_basename(name: str) -> str: |
| 77 | """Reject path components in an output name — this tool writes bare files only.""" |
| 78 | base = name.strip() |
| 79 | if (not base or base in {".", ".."} or ".." in base |
| 80 | or "/" in base or "\\" in base or Path(base).is_absolute()): |
| 81 | raise ValueError(f"unsafe output name {name!r}: must be a bare filename, no path parts") |
| 82 | return base |
| 83 | |
| 84 | |
| 85 | def _sample_bg(cell: Image.Image) -> tuple[int, int, int]: |
| 86 | """Estimate the flat background color from a cell's border ring.""" |
| 87 | rgb = cell.convert("RGB") |
| 88 | w, h = rgb.size |
| 89 | border = max(1, min(_BG_SAMPLE_BORDER, w, h)) |
| 90 | px = rgb.load() |
| 91 | pixels = [] |
| 92 | |
| 93 | for y in range(border): |
| 94 | for x in range(w): |
| 95 | pixels.append(px[x, y]) |
| 96 | |
| 97 | bottom_start = max(border, h - border) |
| 98 | for y in range(bottom_start, h): |
| 99 | for x in range(w): |
| 100 | pixels.append(px[x, y]) |
| 101 | |
| 102 | right_start = max(border, w - border) |
| 103 | for y in range(border, bottom_start): |
| 104 | for x in range(border): |
| 105 | pixels.append(px[x, y]) |
| 106 | for x in range(right_start, w): |
| 107 | pixels.append(px[x, y]) |
| 108 | |
| 109 | return tuple(round(median(channel)) for channel in zip(*pixels)) # type: ignore[return-value] |
| 110 | |
| 111 | |
| 112 | def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image: |
| 113 | """Return the maximum absolute RGB channel difference from the background.""" |
| 114 | diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg)) |
| 115 | red, green, blue = diff.split() |
| 116 | return ImageChops.lighter(ImageChops.lighter(red, green), blue) |
| 117 | |
| 118 | |
| 119 | def _soft_mask_from_diff(diff: Image.Image, tolerance: int) -> Image.Image: |
| 120 | """Build a feathered alpha mask around the tolerance threshold.""" |
| 121 | low = max(0, tolerance - _DEFAULT_FEATHER) |
| 122 | high = min(255, tolerance + _DEFAULT_FEATHER) |
| 123 | if high <= low: |
| 124 | return diff.point(lambda p: 255 if p > tolerance else 0) |
| 125 | |
| 126 | span = high - low |
| 127 | lut = [] |
| 128 | for value in range(256): |
| 129 | if value <= low: |
| 130 | lut.append(0) |
| 131 | elif value >= high: |
| 132 | lut.append(255) |
| 133 | else: |
| 134 | lut.append(round((value - low) * 255 / span)) |
| 135 | return diff.point(lut) |
| 136 | |
| 137 | |
| 138 | def _content_masks( |
| 139 | cell: Image.Image, |
| 140 | bg: tuple[int, int, int], |
| 141 | tolerance: int, |
| 142 | ) -> tuple[Image.Image, Image.Image]: |
| 143 | """Build binary trim and soft alpha masks from the same color distance.""" |
| 144 | diff = _max_channel_difference(cell, bg) |
| 145 | trim_mask = diff.point(lambda p: 255 if p > tolerance else 0) |
| 146 | alpha_mask = _soft_mask_from_diff(diff, tolerance) |
| 147 | alpha_mask = alpha_mask.filter(ImageFilter.MinFilter(3)) |
| 148 | return trim_mask, alpha_mask |
| 149 | |
| 150 | |
| 151 | def slice_sheet( |
| 152 | sheet_path: Path, |
| 153 | rows: int, |
| 154 | cols: int, |
| 155 | output_dir: Path, |
| 156 | *, |
| 157 | names: Optional[list[str]] = None, |
| 158 | prefix: Optional[str] = None, |
| 159 | inset: float = 0.0, |
| 160 | trim: bool = False, |
| 161 | alpha: bool = False, |
| 162 | bg: Optional[tuple[int, int, int]] = None, |
| 163 | tolerance: int = 18, |
| 164 | ) -> list[Path]: |
| 165 | """Slice `sheet_path` into rows*cols element PNGs under `output_dir`. |
| 166 | |
| 167 | Returns the list of written file paths (row-major order). When `names` is |
| 168 | given it must hold exactly rows*cols entries — a mismatch is an error so an |
| 169 | automated run never silently drops cells. Each name must be a bare filename. |
| 170 | """ |
| 171 | total_cells = rows * cols |
| 172 | if names is not None and len(names) != total_cells: |
| 173 | raise ValueError( |
| 174 | f"--names has {len(names)} entries but the {rows}x{cols} grid has " |
| 175 | f"{total_cells} cells; provide exactly one name per cell" |
| 176 | ) |
| 177 | safe_names = [_safe_basename(n) for n in names] if names else None |
| 178 | if safe_names: |
| 179 | seen_outputs: set[str] = set() |
| 180 | for name in safe_names: |
| 181 | output_name = name if Path(name).suffix else f"{name}.png" |
| 182 | normalized_output = output_name.casefold() |
| 183 | if normalized_output in seen_outputs: |
| 184 | raise ValueError( |
| 185 | f"--names repeats output filename {output_name!r} " |
| 186 | "(case-insensitive)" |
| 187 | ) |
| 188 | seen_outputs.add(normalized_output) |
| 189 | if alpha and safe_names: |
| 190 | for name in safe_names: |
| 191 | suffix = Path(name).suffix.lower() |
| 192 | if suffix and suffix != ".png": |
| 193 | raise ValueError(f"--alpha requires .png output names, got {name!r}") |
| 194 | |
| 195 | sheet = Image.open(sheet_path).convert("RGBA") |
| 196 | sw, sh = sheet.size |
| 197 | output_dir.mkdir(parents=True, exist_ok=True) |
| 198 | |
| 199 | stem = sheet_path.stem |
| 200 | name_prefix = _safe_basename(prefix) if prefix else f"{stem}_" |
| 201 | written: list[Path] = [] |
| 202 | |
| 203 | idx = 0 |
| 204 | for r in range(rows): |
| 205 | for c in range(cols): |
| 206 | # Integer cell box via per-index rounding to avoid drift. |
| 207 | x0, x1 = round(c * sw / cols), round((c + 1) * sw / cols) |
| 208 | y0, y1 = round(r * sh / rows), round((r + 1) * sh / rows) |
| 209 | if inset > 0: |
| 210 | dx = round((x1 - x0) * inset) |
| 211 | dy = round((y1 - y0) * inset) |
| 212 | x0, x1, y0, y1 = x0 + dx, x1 - dx, y0 + dy, y1 - dy |
| 213 | cell = sheet.crop((x0, y0, x1, y1)) |
| 214 | |
| 215 | trim_mask: Optional[Image.Image] = None |
| 216 | alpha_mask: Optional[Image.Image] = None |
| 217 | bbox = None |
| 218 | if trim or alpha: |
| 219 | cell_bg = bg if bg is not None else _sample_bg(cell) |
| 220 | trim_mask, alpha_mask = _content_masks(cell, cell_bg, tolerance) |
| 221 | bbox = trim_mask.getbbox() |
| 222 | if bbox is None: |
| 223 | raise ValueError(f"cell ({r},{c}) is all background; no element was sliced") |
| 224 | |
| 225 | if trim and trim_mask is not None and alpha_mask is not None and bbox is not None: |
| 226 | cell = cell.crop(bbox) |
| 227 | alpha_mask = alpha_mask.crop(bbox) |
| 228 | |
| 229 | if alpha and alpha_mask is not None: |
| 230 | cell.putalpha(alpha_mask) |
| 231 | |
| 232 | if safe_names: |
| 233 | out_name = safe_names[idx] |
| 234 | if not Path(out_name).suffix: |
| 235 | out_name += ".png" |
| 236 | else: |
| 237 | out_name = f"{name_prefix}{idx + 1:02d}.png" |
| 238 | out_path = output_dir / out_name |
| 239 | cell.save(out_path) |
| 240 | written.append(out_path) |
| 241 | _log(f"[OK] cell ({r},{c}) -> {out_path.name} ({cell.width}x{cell.height})") |
| 242 | idx += 1 |
| 243 | |
| 244 | if len(written) != total_cells: |
| 245 | raise ValueError(f"sliced {len(written)} elements but expected {total_cells}") |
| 246 | return written |
| 247 | |
| 248 | |
| 249 | def build_parser() -> argparse.ArgumentParser: |
| 250 | """Build the command-line parser.""" |
| 251 | parser = argparse.ArgumentParser( |
| 252 | description="Slice an AI illustration sheet into individual element images.", |
| 253 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 254 | epilog="""Examples: |
| 255 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 |
| 256 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \\ |
| 257 | --names team,product,customer,growth,risk,vision --trim --alpha |
| 258 | """, |
| 259 | ) |
| 260 | parser.add_argument("sheet", help="Path to the generated illustration sheet image") |
| 261 | parser.add_argument("--grid", required=True, help="Grid as 'RxC' (rows x cols), e.g. 2x3") |
| 262 | parser.add_argument( |
| 263 | "-o", "--output", default=None, |
| 264 | help="Output directory (default: the sheet's own directory)", |
| 265 | ) |
| 266 | parser.add_argument( |
| 267 | "--names", default=None, |
| 268 | help="Comma-separated element names, row-major (extension optional). " |
| 269 | "Must provide exactly rows*cols bare filenames.", |
| 270 | ) |
| 271 | parser.add_argument( |
| 272 | "--prefix", default=None, |
| 273 | help="Filename prefix when --names is absent (default: '<sheet-stem>_')", |
| 274 | ) |
| 275 | parser.add_argument( |
| 276 | "--inset", type=float, default=0.0, |
| 277 | help="Trim each cell inward by this fraction on every side (0-0.49) to drop gutters", |
| 278 | ) |
| 279 | parser.add_argument( |
| 280 | "--trim", action="store_true", |
| 281 | help="Tight-crop each cell to its content bounding box", |
| 282 | ) |
| 283 | parser.add_argument( |
| 284 | "--alpha", action="store_true", |
| 285 | help="Make the (flat) background transparent in each element", |
| 286 | ) |
| 287 | parser.add_argument( |
| 288 | "--bg", default=None, |
| 289 | help="Background hex color for --trim/--alpha (default: auto-sample cell border)", |
| 290 | ) |
| 291 | parser.add_argument( |
| 292 | "--tolerance", type=int, default=18, |
| 293 | help="Maximum per-channel color distance treated as background for --trim/--alpha " |
| 294 | "(default: 18)", |
| 295 | ) |
| 296 | return parser |
| 297 | |
| 298 | |
| 299 | def main(argv: Optional[list[str]] = None) -> int: |
| 300 | """Run the CLI entry point.""" |
| 301 | parser = build_parser() |
| 302 | args = parser.parse_args(argv) |
| 303 | |
| 304 | sheet_path = Path(args.sheet) |
| 305 | if not sheet_path.exists(): |
| 306 | print(f"[ERROR] Sheet not found: {sheet_path}", file=sys.stderr) |
| 307 | return 1 |
| 308 | |
| 309 | try: |
| 310 | rows, cols = parse_grid(args.grid) |
| 311 | bg = parse_hex(args.bg) if args.bg else None |
| 312 | except ValueError as exc: |
| 313 | print(f"[ERROR] {exc}", file=sys.stderr) |
| 314 | return 1 |
| 315 | |
| 316 | if not 0.0 <= args.inset < 0.5: |
| 317 | print("[ERROR] --inset must be in [0, 0.5)", file=sys.stderr) |
| 318 | return 1 |
| 319 | if not 0 <= args.tolerance <= 255: |
| 320 | print("[ERROR] --tolerance must be in [0, 255]", file=sys.stderr) |
| 321 | return 1 |
| 322 | |
| 323 | names = [n.strip() for n in args.names.split(",") if n.strip()] if args.names else None |
| 324 | output_dir = Path(args.output) if args.output else sheet_path.parent |
| 325 | |
| 326 | try: |
| 327 | written = slice_sheet( |
| 328 | sheet_path, rows, cols, output_dir, |
| 329 | names=names, prefix=args.prefix, inset=args.inset, |
| 330 | trim=args.trim, alpha=args.alpha, bg=bg, tolerance=args.tolerance, |
| 331 | ) |
| 332 | except (OSError, ValueError) as exc: |
| 333 | print(f"[ERROR] Slicing failed: {exc}", file=sys.stderr) |
| 334 | return 1 |
| 335 | |
| 336 | _log(f"\n[DONE] Wrote {len(written)} element(s) to {output_dir}") |
| 337 | for p in written: |
| 338 | print(p) |
| 339 | return 0 |
| 340 | |
| 341 | |
| 342 | if __name__ == "__main__": |
| 343 | raise SystemExit(main()) |
| 344 |