返回 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 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
344 lines PYTHON