Repository navigation
Expand file tree
/
Copy pathmake_backglass.py
More file actions
309 lines (256 loc) · 11 KB
/
Copy pathmake_backglass.py
File metadata and controls
309 lines (256 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#!/usr/bin/env python3
"""
Pinball FX Backglass Generator
==============================
Takes the template at Template/1.Table.png and composites each PNG or JPG in
src/ beneath it, writing finished backglass images to modified/.
The script asks at startup for two choices:
1. Fit mode — how the source artwork fills the template's transparent area.
Fit / Contain : show the whole image (black bars on sides if needed)
Stretch : fill the area, distorting the image if needed
Cover : fill the area by cropping the top/bottom
2. Output size — the final image resolution.
1080p : 1920 x 1080 (matches the template's native resolution)
4K : 3840 x 2160 (template is upscaled 2x)
You can pass either choice as a CLI flag to skip the prompt:
python3 make_backglass.py --fit --1080
python3 make_backglass.py --stretch --4k
python3 make_backglass.py --cover
Re-running is safe; existing files in modified/ are overwritten.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Pillow availability check — offer to install if missing
# ---------------------------------------------------------------------------
try:
from PIL import Image
except ImportError:
print("Pillow is not installed, but it is required to run this script.")
try:
answer = input("Install it now via pip? [Y/n]: ").strip().lower()
except EOFError:
answer = "y"
if answer in ("", "y", "yes"):
print("Installing Pillow...")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "Pillow"],
check=False,
)
if result.returncode != 0:
raise SystemExit(
"Pillow installation failed. "
"Please run: pip install Pillow and try again."
)
# Re-import after installation
from PIL import Image # noqa: PLC0415
print("Pillow installed successfully.\n")
else:
raise SystemExit(
"Pillow is required. Install it with: pip install Pillow"
)
# --- paths -------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent
TEMPLATE_PATH = ROOT / "Template" / "1.Table.png"
SRC_DIR = ROOT / "src"
OUT_DIR = ROOT / "modified"
# --- configuration -----------------------------------------------------------
# Geometry is defined relative to the native 1920x1080 template, then scaled
# at runtime when the user requests 4K output.
NATIVE_CANVAS = (1920, 1080) # template's native size
NATIVE_BOX = (0, 0, 1920, 878) # left, top, right, bottom of artwork area
BACKGROUND_COLOR = (0, 0, 0, 255) # behind any letterbox bars
# Source file extensions that are accepted
SRC_EXTENSIONS = ("*.png", "*.jpg", "*.jpeg")
# Map user-facing fit-mode names -> internal mode used by fit_image()
MODE_ALIASES = {
"fit": "contain", "contain": "contain", "f": "contain", "1": "contain",
"stretch": "stretch", "s": "stretch", "2": "stretch",
"cover": "cover", "crop": "cover", "c": "cover", "3": "cover",
}
# Map user-facing size names -> (width, height)
SIZE_PRESETS = {
"1080p": (1920, 1080),
"1080": (1920, 1080),
"hd": (1920, 1080),
"1": (1920, 1080),
"4k": (3840, 2160),
"2160p": (3840, 2160),
"2160": (3840, 2160),
"uhd": (3840, 2160),
"2": (3840, 2160),
}
def prompt_fit_mode() -> str:
"""Ask the user how to fit the source artwork. Returns an internal mode name."""
print("How should the source artwork fit the template?")
print(" [1] Fit — show the whole image (black bars on sides if needed)")
print(" [2] Stretch — fill the area, distorting the image if needed")
print(" [3] Cover — fill the area by cropping the top/bottom slightly")
while True:
try:
choice = input("Choice [1/2/3, default 1]: ").strip().lower()
except EOFError:
choice = ""
if choice == "":
return "contain"
if choice in MODE_ALIASES:
return MODE_ALIASES[choice]
print(f" Sorry, '{choice}' isn't a valid option. Try 1, 2, or 3.")
def prompt_output_size() -> tuple[int, int]:
"""Ask the user what final size they want. Returns (width, height)."""
print("What output resolution do you want?")
print(" [1] 1080p — 1920 x 1080 (template's native size)")
print(" [2] 4K — 3840 x 2160 (upscaled 2x)")
while True:
try:
choice = input("Choice [1/2, default 1]: ").strip().lower()
except EOFError:
choice = ""
if choice == "":
return SIZE_PRESETS["1080p"]
if choice in SIZE_PRESETS:
return SIZE_PRESETS[choice]
print(f" Sorry, '{choice}' isn't a valid option. Try 1 or 2.")
def parse_args(argv: list[str]) -> tuple[str | None, tuple[int, int] | None]:
"""Read CLI flags. Each value is None if the user should be prompted."""
parser = argparse.ArgumentParser(
description="Composite src/*.png and src/*.jpg with the backglass template into modified/.",
add_help=True,
)
fit_group = parser.add_mutually_exclusive_group()
fit_group.add_argument("--fit", "--contain", dest="mode", action="store_const",
const="contain", help="Fit the whole image (default).")
fit_group.add_argument("--stretch", dest="mode", action="store_const",
const="stretch", help="Stretch to fill the area.")
fit_group.add_argument("--cover", dest="mode", action="store_const",
const="cover", help="Cover the area by cropping.")
size_group = parser.add_mutually_exclusive_group()
size_group.add_argument("--1080", "--1080p", "--hd", dest="size",
action="store_const", const=SIZE_PRESETS["1080p"],
help="Output at 1920x1080 (default).")
size_group.add_argument("--4k", "--2160p", "--uhd", dest="size",
action="store_const", const=SIZE_PRESETS["4k"],
help="Output at 3840x2160.")
args = parser.parse_args(argv)
return args.mode, args.size
def collect_sources(src_dir: Path) -> list[Path]:
"""Return a sorted, deduplicated list of PNG and JPG files in src_dir.
If a stem appears as both .jpg/.jpeg and .png, the PNG takes priority and
the JPG is skipped (with a warning), to avoid output filename collisions.
"""
seen: dict[str, Path] = {}
all_files: list[Path] = []
for pattern in SRC_EXTENSIONS:
all_files.extend(src_dir.glob(pattern))
for path in sorted(all_files):
stem = path.stem.lower()
if stem in seen:
existing = seen[stem]
# PNG wins; warn and skip the other
if existing.suffix.lower() == ".png":
print(f" SKIP {path.name} (conflicts with {existing.name}; PNG takes priority)")
continue
else:
print(f" SKIP {existing.name} (conflicts with {path.name}; PNG takes priority)")
seen[stem] = path
else:
seen[stem] = path
return sorted(seen.values())
def fit_image(img: Image.Image, box_w: int, box_h: int, mode: str) -> Image.Image:
"""Resize *img* to fit a box of (box_w, box_h) according to *mode*.
Returns an RGBA image exactly box_w x box_h with letterbox padding when
needed (contain) or with the source cropped to fit (cover).
"""
img = img.convert("RGBA")
src_w, src_h = img.size
canvas = Image.new("RGBA", (box_w, box_h), BACKGROUND_COLOR)
if mode == "stretch":
return img.resize((box_w, box_h), Image.LANCZOS)
src_ratio = src_w / src_h
box_ratio = box_w / box_h
if mode == "cover":
# scale so the image fully covers the box, then center-crop
if src_ratio > box_ratio:
new_h = box_h
new_w = int(round(src_ratio * new_h))
else:
new_w = box_w
new_h = int(round(new_w / src_ratio))
resized = img.resize((new_w, new_h), Image.LANCZOS)
left = (new_w - box_w) // 2
top = (new_h - box_h) // 2
return resized.crop((left, top, left + box_w, top + box_h))
# default: contain — fit whole image inside the box, letterbox the rest
if src_ratio > box_ratio:
new_w = box_w
new_h = int(round(new_w / src_ratio))
else:
new_h = box_h
new_w = int(round(src_ratio * new_h))
resized = img.resize((new_w, new_h), Image.LANCZOS)
offset = ((box_w - new_w) // 2, (box_h - new_h) // 2)
canvas.paste(resized, offset)
return canvas
def build_backglass(
source_path: Path,
template: Image.Image,
mode: str,
canvas_size: tuple[int, int],
box: tuple[int, int, int, int],
) -> Image.Image:
"""Composite a single source image (PNG or JPG) with the template."""
src = Image.open(source_path)
box_l, box_t, box_r, box_b = box
box_w, box_h = box_r - box_l, box_b - box_t
fitted = fit_image(src, box_w, box_h, mode)
canvas = Image.new("RGBA", canvas_size, BACKGROUND_COLOR)
canvas.paste(fitted, (box_l, box_t))
canvas.alpha_composite(template) # template's footer overlays the bottom
return canvas
def scale_box(box: tuple[int, int, int, int], scale_x: float, scale_y: float
) -> tuple[int, int, int, int]:
l, t, r, b = box
return (
int(round(l * scale_x)),
int(round(t * scale_y)),
int(round(r * scale_x)),
int(round(b * scale_y)),
)
def main(argv: list[str] | None = None) -> None:
if not TEMPLATE_PATH.exists():
raise SystemExit(f"Template not found: {TEMPLATE_PATH}")
mode, size = parse_args(sys.argv[1:] if argv is None else argv)
if mode is None:
mode = prompt_fit_mode()
if size is None:
size = prompt_output_size()
print(f"Using fit mode: {mode}")
print(f"Output size: {size[0]}x{size[1]}")
OUT_DIR.mkdir(parents=True, exist_ok=True)
template = Image.open(TEMPLATE_PATH).convert("RGBA")
if template.size != NATIVE_CANVAS:
template = template.resize(NATIVE_CANVAS, Image.LANCZOS)
if size != NATIVE_CANVAS:
template = template.resize(size, Image.LANCZOS)
scale_x = size[0] / NATIVE_CANVAS[0]
scale_y = size[1] / NATIVE_CANVAS[1]
box = scale_box(NATIVE_BOX, scale_x, scale_y)
sources = collect_sources(SRC_DIR)
if not sources:
print(f"No PNG or JPG files found in {SRC_DIR}")
return
print(f"Processing {len(sources)} file(s) -> {OUT_DIR}")
for src_path in sources:
# Always write output as PNG regardless of input format
out_path = OUT_DIR / (src_path.stem + ".png")
try:
result = build_backglass(src_path, template, mode, size, box)
result.save(out_path, "PNG")
print(f" ok {src_path.name}")
except Exception as exc:
print(f" FAIL {src_path.name}: {exc}")
if __name__ == "__main__":
main()