| 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 the dominant flat field |
| 18 | unless you pass --bg. Keying only works on a genuinely flat ground, so each |
| 19 | element is checked before writing. --strict-alpha turns an incomplete key into |
| 20 | an error with no output files. An explicit pure red, green, or blue key also |
| 21 | recovers clean RGB and partial alpha for antialiasing, shadows, and glows. See |
| 22 | references/image-generator.md section 4.3 for the sheet contract. |
| 23 | |
| 24 | Usage: |
| 25 | python3 scripts/slice_images.py <sheet_image> --grid RxC [options] |
| 26 | |
| 27 | Examples: |
| 28 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 |
| 29 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \ |
| 30 | --names team,product,customer,growth,risk,vision --trim --alpha \ |
| 31 | --bg "#00FF00" --strict-alpha |
| 32 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 1x4 \ |
| 33 | --prefix spot_ --bg "#F8F9FA" --alpha |
| 34 | |
| 35 | Dependencies: |
| 36 | Pillow |
| 37 | """ |
| 38 | |
| 39 | import argparse |
| 40 | import re |
| 41 | import sys |
| 42 | from collections import Counter |
| 43 | from pathlib import Path |
| 44 | from statistics import median |
| 45 | from typing import Optional |
| 46 | |
| 47 | from console_encoding import configure_utf8_stdio |
| 48 | |
| 49 | configure_utf8_stdio() |
| 50 | |
| 51 | from PIL import ( |
| 52 | Image, |
| 53 | ImageChops, |
| 54 | ImageFilter, |
| 55 | ImageMath, |
| 56 | ) |
| 57 | |
| 58 | _GRID_RE = re.compile(r"^\s*(\d+)\s*[xX×]\s*(\d+)\s*$") |
| 59 | _BG_BUCKET_SIZE = 16 |
| 60 | _BG_SAMPLE_BORDER = 2 |
| 61 | _BG_SAMPLE_MAX_SIDE = 256 |
| 62 | _DEFAULT_FEATHER = 4 |
| 63 | _BOUNDARY_OPAQUE_ALPHA = 32 |
| 64 | |
| 65 | |
| 66 | def _log(msg: str) -> None: |
| 67 | """Print progress to stderr (stdout carries the created file paths).""" |
| 68 | print(msg, file=sys.stderr) |
| 69 | |
| 70 | |
| 71 | def parse_grid(spec: str) -> tuple[int, int]: |
| 72 | """Parse a 'RxC' grid spec into (rows, cols).""" |
| 73 | m = _GRID_RE.match(spec) |
| 74 | if not m: |
| 75 | raise ValueError(f"--grid must look like '2x3' (rows x cols), got {spec!r}") |
| 76 | rows, cols = int(m.group(1)), int(m.group(2)) |
| 77 | if rows < 1 or cols < 1: |
| 78 | raise ValueError(f"--grid rows and cols must be >= 1, got {rows}x{cols}") |
| 79 | return rows, cols |
| 80 | |
| 81 | |
| 82 | def parse_hex(value: str) -> tuple[int, int, int]: |
| 83 | """Parse '#RRGGBB' / 'RRGGBB' into an (r, g, b) tuple.""" |
| 84 | h = value.strip().lstrip("#") |
| 85 | if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h): |
| 86 | raise ValueError(f"--bg must be a 6-digit hex color, got {value!r}") |
| 87 | return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) |
| 88 | |
| 89 | |
| 90 | def _safe_basename(name: str) -> str: |
| 91 | """Reject path components in an output name — this tool writes bare files only.""" |
| 92 | base = name.strip() |
| 93 | if (not base or base in {".", ".."} or ".." in base |
| 94 | or "/" in base or "\\" in base or Path(base).is_absolute()): |
| 95 | raise ValueError(f"unsafe output name {name!r}: must be a bare filename, no path parts") |
| 96 | return base |
| 97 | |
| 98 | |
| 99 | def _sample_border_bg(rgb: Image.Image) -> tuple[int, int, int]: |
| 100 | """Estimate a background candidate from a cell's border ring.""" |
| 101 | w, h = rgb.size |
| 102 | border = max(1, min(_BG_SAMPLE_BORDER, w, h)) |
| 103 | px = rgb.load() |
| 104 | pixels = [] |
| 105 | |
| 106 | for y in range(border): |
| 107 | for x in range(w): |
| 108 | pixels.append(px[x, y]) |
| 109 | |
| 110 | bottom_start = max(border, h - border) |
| 111 | for y in range(bottom_start, h): |
| 112 | for x in range(w): |
| 113 | pixels.append(px[x, y]) |
| 114 | |
| 115 | right_start = max(border, w - border) |
| 116 | for y in range(border, bottom_start): |
| 117 | for x in range(border): |
| 118 | pixels.append(px[x, y]) |
| 119 | for x in range(right_start, w): |
| 120 | pixels.append(px[x, y]) |
| 121 | |
| 122 | return tuple(round(median(channel)) for channel in zip(*pixels)) # type: ignore[return-value] |
| 123 | |
| 124 | |
| 125 | def _sample_pixels(rgb: Image.Image) -> list[tuple[int, int, int]]: |
| 126 | """Return a bounded RGB sample for background-candidate scoring.""" |
| 127 | sample = rgb.copy() |
| 128 | sample.thumbnail( |
| 129 | (_BG_SAMPLE_MAX_SIDE, _BG_SAMPLE_MAX_SIDE), |
| 130 | Image.Resampling.NEAREST, |
| 131 | ) |
| 132 | raw = sample.tobytes() |
| 133 | return list(zip(raw[0::3], raw[1::3], raw[2::3])) |
| 134 | |
| 135 | |
| 136 | def _dominant_bg_candidate( |
| 137 | pixels: list[tuple[int, int, int]], |
| 138 | ) -> tuple[int, int, int]: |
| 139 | """Estimate the dominant flat field from quantized sampled pixels.""" |
| 140 | buckets = Counter( |
| 141 | tuple(channel // _BG_BUCKET_SIZE for channel in pixel) |
| 142 | for pixel in pixels |
| 143 | ) |
| 144 | dominant_bucket = buckets.most_common(1)[0][0] |
| 145 | members = [ |
| 146 | pixel |
| 147 | for pixel in pixels |
| 148 | if tuple(channel // _BG_BUCKET_SIZE for channel in pixel) == dominant_bucket |
| 149 | ] |
| 150 | return tuple(round(median(channel)) for channel in zip(*members)) # type: ignore[return-value] |
| 151 | |
| 152 | |
| 153 | def _background_coverage( |
| 154 | pixels: list[tuple[int, int, int]], |
| 155 | candidate: tuple[int, int, int], |
| 156 | tolerance: int, |
| 157 | ) -> int: |
| 158 | """Count sampled pixels close enough to a background candidate.""" |
| 159 | return sum( |
| 160 | max(abs(pixel[index] - candidate[index]) for index in range(3)) <= tolerance |
| 161 | for pixel in pixels |
| 162 | ) |
| 163 | |
| 164 | |
| 165 | def _sample_bg(cell: Image.Image, tolerance: int) -> tuple[int, int, int]: |
| 166 | """Choose the background candidate that covers most of the cell.""" |
| 167 | rgb = cell.convert("RGB") |
| 168 | pixels = _sample_pixels(rgb) |
| 169 | candidates = ( |
| 170 | _sample_border_bg(rgb), |
| 171 | _dominant_bg_candidate(pixels), |
| 172 | ) |
| 173 | return max( |
| 174 | candidates, |
| 175 | key=lambda candidate: _background_coverage(pixels, candidate, tolerance), |
| 176 | ) |
| 177 | |
| 178 | |
| 179 | def _max_channel_difference(cell: Image.Image, bg: tuple[int, int, int]) -> Image.Image: |
| 180 | """Return the maximum absolute RGB channel difference from the background.""" |
| 181 | diff = ImageChops.difference(cell.convert("RGB"), Image.new("RGB", cell.size, bg)) |
| 182 | red, green, blue = diff.split() |
| 183 | return ImageChops.lighter(ImageChops.lighter(red, green), blue) |
| 184 | |
| 185 | |
| 186 | def _pure_chroma_channel(bg: tuple[int, int, int]) -> Optional[int]: |
| 187 | """Return the active channel for an exact pure RGB key, if any.""" |
| 188 | if bg.count(255) != 1 or bg.count(0) != 2: |
| 189 | return None |
| 190 | return bg.index(255) |
| 191 | |
| 192 | |
| 193 | def _channel_alpha(channel: Image.Image, bg_value: int) -> Image.Image: |
| 194 | """Return the minimum alpha that can explain one channel over a key.""" |
| 195 | lut = [] |
| 196 | for value in range(256): |
| 197 | if value > bg_value: |
| 198 | denominator = 255 - bg_value |
| 199 | alpha = 255 if denominator == 0 else round( |
| 200 | (value - bg_value) * 255 / denominator |
| 201 | ) |
| 202 | elif value < bg_value: |
| 203 | alpha = 255 if bg_value == 0 else round( |
| 204 | (bg_value - value) * 255 / bg_value |
| 205 | ) |
| 206 | else: |
| 207 | alpha = 0 |
| 208 | lut.append(alpha) |
| 209 | return channel.point(lut) |
| 210 | |
| 211 | |
| 212 | def _chroma_alpha(rgb: Image.Image, bg: tuple[int, int, int]) -> Image.Image: |
| 213 | """Recover foreground opacity for a pure single-channel chroma key. |
| 214 | |
| 215 | A non-key-dominant pixel is treated as opaque foreground. A key-dominant |
| 216 | pixel uses color-to-alpha recovery, which preserves soft shadows, glows, |
| 217 | and antialiased edges without making an ordinary solid foreground color |
| 218 | unnecessarily translucent. |
| 219 | """ |
| 220 | channels = rgb.split() |
| 221 | channel_alphas = [ |
| 222 | _channel_alpha(channel, bg_value) |
| 223 | for channel, bg_value in zip(channels, bg) |
| 224 | ] |
| 225 | raw_alpha = ImageChops.lighter( |
| 226 | ImageChops.lighter(channel_alphas[0], channel_alphas[1]), |
| 227 | channel_alphas[2], |
| 228 | ) |
| 229 | key_index = _pure_chroma_channel(bg) |
| 230 | if key_index is None: |
| 231 | return raw_alpha |
| 232 | |
| 233 | other_channels = [ |
| 234 | channel for index, channel in enumerate(channels) if index != key_index |
| 235 | ] |
| 236 | key_excess = ImageChops.subtract( |
| 237 | channels[key_index], |
| 238 | ImageChops.lighter(other_channels[0], other_channels[1]), |
| 239 | ) |
| 240 | key_dominance = key_excess.point(lambda value: 255 if value > 0 else 0) |
| 241 | opaque = Image.new("L", rgb.size, 255) |
| 242 | return Image.composite(raw_alpha, opaque, key_dominance) |
| 243 | |
| 244 | |
| 245 | def _decontaminate_channel( |
| 246 | channel: Image.Image, |
| 247 | alpha: Image.Image, |
| 248 | bg_value: int, |
| 249 | ) -> Image.Image: |
| 250 | """Remove a composited key channel while supporting Pillow 9 through 12.""" |
| 251 | if hasattr(ImageMath, "lambda_eval"): |
| 252 | return ImageMath.lambda_eval( |
| 253 | lambda op: op["convert"]( |
| 254 | bg_value |
| 255 | + (op["channel"] - bg_value) |
| 256 | * 255 |
| 257 | / op["max"](op["alpha"], 1), |
| 258 | "L", |
| 259 | ), |
| 260 | channel=channel, |
| 261 | alpha=alpha, |
| 262 | ) |
| 263 | return ImageMath.eval( # type: ignore[attr-defined] |
| 264 | "convert(bg + (channel - bg) * 255 / max(alpha, 1), 'L')", |
| 265 | channel=channel, |
| 266 | alpha=alpha, |
| 267 | bg=bg_value, |
| 268 | ) |
| 269 | |
| 270 | |
| 271 | def _decontaminate_rgb( |
| 272 | rgb: Image.Image, |
| 273 | alpha: Image.Image, |
| 274 | bg: tuple[int, int, int], |
| 275 | ) -> Image.Image: |
| 276 | """Recover foreground RGB values from a composited pure chroma key.""" |
| 277 | return Image.merge( |
| 278 | "RGB", |
| 279 | tuple( |
| 280 | _decontaminate_channel(channel, alpha, bg_value) |
| 281 | for channel, bg_value in zip(rgb.split(), bg) |
| 282 | ), |
| 283 | ) |
| 284 | |
| 285 | |
| 286 | def _soft_mask_from_diff(diff: Image.Image, tolerance: int) -> Image.Image: |
| 287 | """Build a feathered alpha mask around the tolerance threshold.""" |
| 288 | low = max(0, tolerance - _DEFAULT_FEATHER) |
| 289 | high = min(255, tolerance + _DEFAULT_FEATHER) |
| 290 | if high <= low: |
| 291 | return diff.point(lambda p: 255 if p > tolerance else 0) |
| 292 | |
| 293 | span = high - low |
| 294 | lut = [] |
| 295 | for value in range(256): |
| 296 | if value <= low: |
| 297 | lut.append(0) |
| 298 | elif value >= high: |
| 299 | lut.append(255) |
| 300 | else: |
| 301 | lut.append(round((value - low) * 255 / span)) |
| 302 | return diff.point(lut) |
| 303 | |
| 304 | |
| 305 | def _content_masks( |
| 306 | cell: Image.Image, |
| 307 | bg: tuple[int, int, int], |
| 308 | tolerance: int, |
| 309 | ) -> tuple[Image.Image, Image.Image, Optional[Image.Image]]: |
| 310 | """Build trim/alpha masks and optional chroma-decontaminated RGB.""" |
| 311 | rgb = cell.convert("RGB") |
| 312 | diff = _max_channel_difference(rgb, bg) |
| 313 | trim_mask = diff.point(lambda p: 255 if p > tolerance else 0) |
| 314 | tolerance_gate = _soft_mask_from_diff(diff, tolerance) |
| 315 | if _pure_chroma_channel(bg) is not None: |
| 316 | chroma_alpha = _chroma_alpha(rgb, bg) |
| 317 | alpha_mask = ImageChops.multiply(chroma_alpha, tolerance_gate) |
| 318 | keyed_rgb = _decontaminate_rgb(rgb, chroma_alpha, bg) |
| 319 | return trim_mask, alpha_mask, keyed_rgb |
| 320 | |
| 321 | alpha_mask = tolerance_gate.filter(ImageFilter.MinFilter(3)) |
| 322 | return trim_mask, alpha_mask, None |
| 323 | |
| 324 | |
| 325 | def _keying_findings( |
| 326 | label: str, |
| 327 | cell_size: tuple[int, int], |
| 328 | bbox: tuple[int, int, int, int], |
| 329 | alpha_mask: Optional[Image.Image], |
| 330 | cell_bg: tuple[int, int, int], |
| 331 | *, |
| 332 | trim: bool, |
| 333 | alpha: bool, |
| 334 | ) -> list[str]: |
| 335 | """Report objective signs that the flat-background key did not take. |
| 336 | |
| 337 | Two deterministic symptoms: a cut element whose cell boundary is still |
| 338 | opaque, and content that reaches any cell edge. Both violate the clear-key |
| 339 | gutter required by the sheet contract, usually because the ground is not |
| 340 | flat or an element/effect crossed its cell boundary. |
| 341 | """ |
| 342 | findings: list[str] = [] |
| 343 | hex_bg = "#{:02X}{:02X}{:02X}".format(*cell_bg) |
| 344 | |
| 345 | if alpha and alpha_mask is not None: |
| 346 | px = alpha_mask.load() |
| 347 | width, height = alpha_mask.size |
| 348 | border = max(1, min(_BG_SAMPLE_BORDER, width, height)) |
| 349 | boundary = [] |
| 350 | for y in range(border): |
| 351 | boundary.extend(px[x, y] for x in range(width)) |
| 352 | for y in range(max(border, height - border), height): |
| 353 | boundary.extend(px[x, y] for x in range(width)) |
| 354 | for y in range(border, max(border, height - border)): |
| 355 | boundary.extend(px[x, y] for x in range(border)) |
| 356 | boundary.extend( |
| 357 | px[x, y] for x in range(max(border, width - border), width) |
| 358 | ) |
| 359 | opaque = sum( |
| 360 | 1 for value in boundary if value > _BOUNDARY_OPAQUE_ALPHA |
| 361 | ) |
| 362 | if opaque: |
| 363 | findings.append( |
| 364 | f"{label}: {opaque}/{len(boundary)} boundary pixels stayed opaque " |
| 365 | f"after --alpha " |
| 366 | f"(sampled background {hex_bg})" |
| 367 | ) |
| 368 | |
| 369 | if trim: |
| 370 | cell_width, cell_height = cell_size |
| 371 | touched_edges = [] |
| 372 | if bbox[0] <= 0: |
| 373 | touched_edges.append("left") |
| 374 | if bbox[1] <= 0: |
| 375 | touched_edges.append("top") |
| 376 | if bbox[2] >= cell_width: |
| 377 | touched_edges.append("right") |
| 378 | if bbox[3] >= cell_height: |
| 379 | touched_edges.append("bottom") |
| 380 | if touched_edges: |
| 381 | findings.append( |
| 382 | f"{label}: content reaches the {'/'.join(touched_edges)} cell edge(s) " |
| 383 | f"(sampled background {hex_bg})" |
| 384 | ) |
| 385 | |
| 386 | return findings |
| 387 | |
| 388 | |
| 389 | def _log_keying_findings(findings: list[str]) -> None: |
| 390 | """Report incomplete flat-background keying.""" |
| 391 | _log("\n[WARN] Alpha extraction is incomplete — the key field or cell") |
| 392 | _log(" isolation failed:") |
| 393 | for finding in findings: |
| 394 | _log(f" - {finding}") |
| 395 | _log(" Fix: regenerate with one genuinely flat ground and keep every " |
| 396 | "element/effect") |
| 397 | _log(" inside its cell with a clear key-only gutter, or rerun with an " |
| 398 | "explicit") |
| 399 | _log(" --bg <hex> and a larger --tolerance; use --inset when a drawn " |
| 400 | "outer gutter is isolated from every element.") |
| 401 | |
| 402 | |
| 403 | def slice_sheet( |
| 404 | sheet_path: Path, |
| 405 | rows: int, |
| 406 | cols: int, |
| 407 | output_dir: Path, |
| 408 | *, |
| 409 | names: Optional[list[str]] = None, |
| 410 | prefix: Optional[str] = None, |
| 411 | inset: float = 0.0, |
| 412 | trim: bool = False, |
| 413 | alpha: bool = False, |
| 414 | strict_alpha: bool = False, |
| 415 | bg: Optional[tuple[int, int, int]] = None, |
| 416 | tolerance: int = 18, |
| 417 | ) -> list[Path]: |
| 418 | """Slice `sheet_path` into rows*cols element PNGs under `output_dir`. |
| 419 | |
| 420 | Returns the list of written file paths (row-major order). When `names` is |
| 421 | given it must hold exactly rows*cols entries — a mismatch is an error so an |
| 422 | automated run never silently drops cells. Each name must be a bare filename. |
| 423 | """ |
| 424 | total_cells = rows * cols |
| 425 | if strict_alpha and not alpha: |
| 426 | raise ValueError("strict_alpha requires alpha=True") |
| 427 | if names is not None and len(names) != total_cells: |
| 428 | raise ValueError( |
| 429 | f"--names has {len(names)} entries but the {rows}x{cols} grid has " |
| 430 | f"{total_cells} cells; provide exactly one name per cell" |
| 431 | ) |
| 432 | safe_names = [_safe_basename(n) for n in names] if names else None |
| 433 | if safe_names: |
| 434 | seen_outputs: set[str] = set() |
| 435 | for name in safe_names: |
| 436 | output_name = name if Path(name).suffix else f"{name}.png" |
| 437 | normalized_output = output_name.casefold() |
| 438 | if normalized_output in seen_outputs: |
| 439 | raise ValueError( |
| 440 | f"--names repeats output filename {output_name!r} " |
| 441 | "(case-insensitive)" |
| 442 | ) |
| 443 | seen_outputs.add(normalized_output) |
| 444 | if alpha and safe_names: |
| 445 | for name in safe_names: |
| 446 | suffix = Path(name).suffix.lower() |
| 447 | if suffix and suffix != ".png": |
| 448 | raise ValueError(f"--alpha requires .png output names, got {name!r}") |
| 449 | |
| 450 | sheet = Image.open(sheet_path).convert("RGBA") |
| 451 | sw, sh = sheet.size |
| 452 | output_dir.mkdir(parents=True, exist_ok=True) |
| 453 | |
| 454 | stem = sheet_path.stem |
| 455 | name_prefix = _safe_basename(prefix) if prefix else f"{stem}_" |
| 456 | prepared: list[tuple[int, int, Image.Image, Path]] = [] |
| 457 | written: list[Path] = [] |
| 458 | findings: list[str] = [] |
| 459 | |
| 460 | idx = 0 |
| 461 | for r in range(rows): |
| 462 | for c in range(cols): |
| 463 | # Integer cell box via per-index rounding to avoid drift. |
| 464 | x0, x1 = round(c * sw / cols), round((c + 1) * sw / cols) |
| 465 | y0, y1 = round(r * sh / rows), round((r + 1) * sh / rows) |
| 466 | if inset > 0: |
| 467 | dx = round((x1 - x0) * inset) |
| 468 | dy = round((y1 - y0) * inset) |
| 469 | x0, x1, y0, y1 = x0 + dx, x1 - dx, y0 + dy, y1 - dy |
| 470 | cell = sheet.crop((x0, y0, x1, y1)) |
| 471 | |
| 472 | trim_mask: Optional[Image.Image] = None |
| 473 | alpha_mask: Optional[Image.Image] = None |
| 474 | keyed_rgb: Optional[Image.Image] = None |
| 475 | bbox = None |
| 476 | if trim or alpha: |
| 477 | cell_bg = bg if bg is not None else _sample_bg(cell, tolerance) |
| 478 | trim_mask, alpha_mask, keyed_rgb = _content_masks( |
| 479 | cell, cell_bg, tolerance |
| 480 | ) |
| 481 | bbox = trim_mask.getbbox() |
| 482 | if bbox is None: |
| 483 | raise ValueError(f"cell ({r},{c}) is all background; no element was sliced") |
| 484 | findings.extend(_keying_findings( |
| 485 | f"cell ({r},{c})", cell.size, bbox, alpha_mask, cell_bg, |
| 486 | trim=trim, alpha=alpha, |
| 487 | )) |
| 488 | |
| 489 | if trim and trim_mask is not None and alpha_mask is not None and bbox is not None: |
| 490 | cell = cell.crop(bbox) |
| 491 | alpha_mask = alpha_mask.crop(bbox) |
| 492 | if keyed_rgb is not None: |
| 493 | keyed_rgb = keyed_rgb.crop(bbox) |
| 494 | |
| 495 | if alpha and alpha_mask is not None: |
| 496 | if keyed_rgb is not None: |
| 497 | cell = keyed_rgb.convert("RGBA") |
| 498 | cell.putalpha(alpha_mask) |
| 499 | |
| 500 | if safe_names: |
| 501 | out_name = safe_names[idx] |
| 502 | if not Path(out_name).suffix: |
| 503 | out_name += ".png" |
| 504 | else: |
| 505 | out_name = f"{name_prefix}{idx + 1:02d}.png" |
| 506 | out_path = output_dir / out_name |
| 507 | prepared.append((r, c, cell, out_path)) |
| 508 | idx += 1 |
| 509 | |
| 510 | if findings: |
| 511 | _log_keying_findings(findings) |
| 512 | if strict_alpha: |
| 513 | raise ValueError( |
| 514 | "strict alpha validation found incomplete background keying; " |
| 515 | "no output files were written" |
| 516 | ) |
| 517 | |
| 518 | for r, c, cell, out_path in prepared: |
| 519 | cell.save(out_path) |
| 520 | written.append(out_path) |
| 521 | _log(f"[OK] cell ({r},{c}) -> {out_path.name} ({cell.width}x{cell.height})") |
| 522 | |
| 523 | if len(written) != total_cells: |
| 524 | raise ValueError(f"sliced {len(written)} elements but expected {total_cells}") |
| 525 | |
| 526 | return written |
| 527 | |
| 528 | |
| 529 | def build_parser() -> argparse.ArgumentParser: |
| 530 | """Build the command-line parser.""" |
| 531 | parser = argparse.ArgumentParser( |
| 532 | description="Slice an AI illustration sheet into individual element images.", |
| 533 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 534 | epilog="""Examples: |
| 535 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 |
| 536 | python3 scripts/slice_images.py projects/demo/images/illus_sheet.png --grid 2x3 \\ |
| 537 | --names team,product,customer,growth,risk,vision --trim --alpha \\ |
| 538 | --bg "#00FF00" --strict-alpha |
| 539 | """, |
| 540 | ) |
| 541 | parser.add_argument("sheet", help="Path to the generated illustration sheet image") |
| 542 | parser.add_argument("--grid", required=True, help="Grid as 'RxC' (rows x cols), e.g. 2x3") |
| 543 | parser.add_argument( |
| 544 | "-o", "--output", default=None, |
| 545 | help="Output directory (default: the sheet's own directory)", |
| 546 | ) |
| 547 | parser.add_argument( |
| 548 | "--names", default=None, |
| 549 | help="Comma-separated element names, row-major (extension optional). " |
| 550 | "Must provide exactly rows*cols bare filenames.", |
| 551 | ) |
| 552 | parser.add_argument( |
| 553 | "--prefix", default=None, |
| 554 | help="Filename prefix when --names is absent (default: '<sheet-stem>_')", |
| 555 | ) |
| 556 | parser.add_argument( |
| 557 | "--inset", type=float, default=0.0, |
| 558 | help="Trim each cell inward by this fraction on every side (0-0.49) to drop gutters", |
| 559 | ) |
| 560 | parser.add_argument( |
| 561 | "--trim", action="store_true", |
| 562 | help="Tight-crop each cell to its content bounding box", |
| 563 | ) |
| 564 | parser.add_argument( |
| 565 | "--alpha", action="store_true", |
| 566 | help="Make the (flat) background transparent in each element", |
| 567 | ) |
| 568 | parser.add_argument( |
| 569 | "--strict-alpha", action="store_true", |
| 570 | help="Fail without writing outputs when --alpha validation finds incomplete keying", |
| 571 | ) |
| 572 | parser.add_argument( |
| 573 | "--bg", default=None, |
| 574 | help="Background hex color for --trim/--alpha; an exact pure red/green/blue " |
| 575 | "key enables despill and soft-alpha recovery (default: auto-sample)", |
| 576 | ) |
| 577 | parser.add_argument( |
| 578 | "--tolerance", type=int, default=18, |
| 579 | help="Maximum per-channel color distance treated as background for --trim/--alpha " |
| 580 | "(default: 18)", |
| 581 | ) |
| 582 | return parser |
| 583 | |
| 584 | |
| 585 | def main(argv: Optional[list[str]] = None) -> int: |
| 586 | """Run the CLI entry point.""" |
| 587 | parser = build_parser() |
| 588 | args = parser.parse_args(argv) |
| 589 | |
| 590 | sheet_path = Path(args.sheet) |
| 591 | if not sheet_path.exists(): |
| 592 | print(f"[ERROR] Sheet not found: {sheet_path}", file=sys.stderr) |
| 593 | return 1 |
| 594 | |
| 595 | try: |
| 596 | rows, cols = parse_grid(args.grid) |
| 597 | bg = parse_hex(args.bg) if args.bg else None |
| 598 | except ValueError as exc: |
| 599 | print(f"[ERROR] {exc}", file=sys.stderr) |
| 600 | return 1 |
| 601 | |
| 602 | if not 0.0 <= args.inset < 0.5: |
| 603 | print("[ERROR] --inset must be in [0, 0.5)", file=sys.stderr) |
| 604 | return 1 |
| 605 | if not 0 <= args.tolerance <= 255: |
| 606 | print("[ERROR] --tolerance must be in [0, 255]", file=sys.stderr) |
| 607 | return 1 |
| 608 | if args.strict_alpha and not args.alpha: |
| 609 | print("[ERROR] --strict-alpha requires --alpha", file=sys.stderr) |
| 610 | return 1 |
| 611 | |
| 612 | names = [n.strip() for n in args.names.split(",") if n.strip()] if args.names else None |
| 613 | output_dir = Path(args.output) if args.output else sheet_path.parent |
| 614 | |
| 615 | try: |
| 616 | written = slice_sheet( |
| 617 | sheet_path, rows, cols, output_dir, |
| 618 | names=names, prefix=args.prefix, inset=args.inset, |
| 619 | trim=args.trim, alpha=args.alpha, strict_alpha=args.strict_alpha, |
| 620 | bg=bg, tolerance=args.tolerance, |
| 621 | ) |
| 622 | except (OSError, ValueError) as exc: |
| 623 | print(f"[ERROR] Slicing failed: {exc}", file=sys.stderr) |
| 624 | return 1 |
| 625 | |
| 626 | _log(f"\n[DONE] Wrote {len(written)} element(s) to {output_dir}") |
| 627 | for p in written: |
| 628 | print(p) |
| 629 | return 0 |
| 630 | |
| 631 | |
| 632 | if __name__ == "__main__": |
| 633 | raise SystemExit(main()) |
| 634 |