返回 ppt-master
slice_images.py
根目录 / skills / ppt-master / scripts / slice_images.py
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
634 lines PYTHON