Created
February 25, 2026 23:41
-
-
Save larrasket/2668625ddf7fe9be2a86303ad71640a7 to your computer and use it in GitHub Desktop.
Processes images for macOS "Fit to Screen" wallpaper mode so image content is GUARANTEED to never appear under the menu bar or dock.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| process_wallpapers.py | |
| Processes images for macOS "Fit to Screen" wallpaper mode so image content | |
| is GUARANTEED to never appear under the menu bar or dock. | |
| Strategy | |
| -------- | |
| Every output image is a full screen-sized canvas (screen_width × screen_height). | |
| The image content is scaled to fit inside a safe inner rectangle: | |
| inner width = screen_width | |
| inner height = screen_height - top_padding - bottom_padding | |
| …while preserving the original aspect ratio. The scaled image is then centered | |
| inside that inner rectangle, and the whole canvas (with black bars top and | |
| bottom) becomes the wallpaper file. | |
| When macOS scales this canvas with "Fit to Screen" it maps it 1-to-1 to the | |
| display. The black bars sit exactly over the menu bar and dock regions — | |
| image content is guaranteed to stay inside the safe zone. | |
| Originals are never modified. All output goes to a separate folder. | |
| Usage | |
| ----- | |
| python3 process_wallpapers.py [OPTIONS] INPUT_FOLDER OUTPUT_FOLDER | |
| Options | |
| ------- | |
| --screen-width INT Screen width in points/pixels (default: 2560) | |
| --screen-height INT Screen height in points/pixels (default: 1600) | |
| --top-padding INT Minimum gap above image content (default: 100) | |
| --bottom-padding INT Minimum gap below image content (default: 100) | |
| --bg-color STR Canvas colour as R,G,B (default: 0,0,0) | |
| --suffix STR Suffix added to output filenames(default: _padded) | |
| --help Show this help and exit | |
| """ | |
| import argparse | |
| import sys | |
| from pathlib import Path | |
| SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp", ".webp", ".heic"} | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # CLI | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def parse_args(): | |
| parser = argparse.ArgumentParser( | |
| description="Prepare images for macOS 'Fit to Screen' wallpaper with guaranteed safe-zone padding.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=__doc__, | |
| ) | |
| parser.add_argument("input_folder", type=Path, help="Folder containing source images") | |
| parser.add_argument("output_folder", type=Path, help="Folder for processed images") | |
| parser.add_argument("--screen-width", type=int, default=2560, help="Screen width (px, default 2560)") | |
| parser.add_argument("--screen-height", type=int, default=1600, help="Screen height (px, default 1600)") | |
| parser.add_argument("--top-padding", type=int, default=100, help="Min pixels above content (default 100)") | |
| parser.add_argument("--bottom-padding", type=int, default=100, help="Min pixels below content (default 100)") | |
| parser.add_argument("--bg-color", type=str, default="0,0,0", | |
| help="Canvas background colour as R,G,B (default: 0,0,0 = black)") | |
| parser.add_argument("--suffix", type=str, default="_padded", | |
| help="Suffix appended before extension on output files (default: _padded)") | |
| return parser.parse_args() | |
| def parse_color(raw: str): | |
| try: | |
| parts = [int(x.strip()) for x in raw.split(",")] | |
| if len(parts) != 3 or not all(0 <= p <= 255 for p in parts): | |
| raise ValueError | |
| return tuple(parts) | |
| except ValueError: | |
| print(f"ERROR: --bg-color must be three integers 0-255 separated by commas, e.g. '0,0,0'. Got: {raw!r}") | |
| sys.exit(1) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Pillow bootstrap | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def ensure_pillow(): | |
| try: | |
| from PIL import Image # noqa: F401 | |
| except ImportError: | |
| print("Pillow is not installed. Installing now …") | |
| import subprocess | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", "Pillow", "--quiet"]) | |
| print("Pillow installed successfully.\n") | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Core processing | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def process_image( | |
| src_path: Path, | |
| output_folder: Path, | |
| screen_w: int, | |
| screen_h: int, | |
| top_pad: int, | |
| bot_pad: int, | |
| bg_color: tuple, | |
| suffix: str, | |
| ) -> Path: | |
| from PIL import Image, ImageOps | |
| img = Image.open(src_path) | |
| # Honour EXIF rotation so portrait shots are not treated as landscape. | |
| try: | |
| img = ImageOps.exif_transpose(img) | |
| except Exception: | |
| pass | |
| orig_w, orig_h = img.size | |
| orig_mode = img.mode | |
| # ── Safe inner rectangle ────────────────────────────────────────────────── | |
| # Maximum pixel space available for image content after reserving margins. | |
| inner_w = screen_w | |
| inner_h = screen_h - top_pad - bot_pad | |
| if inner_h <= 0: | |
| raise ValueError( | |
| f"top_padding ({top_pad}) + bottom_padding ({bot_pad}) " | |
| f">= screen_height ({screen_h}). No room for image content." | |
| ) | |
| # ── Scale image to fit inside the inner rectangle (box-fit) ────────────── | |
| # Constrain both width and height; preserve aspect ratio. | |
| scale = min(inner_w / orig_w, inner_h / orig_h) | |
| if scale < 1.0: | |
| fit_w = max(1, round(orig_w * scale)) | |
| fit_h = max(1, round(orig_h * scale)) | |
| img = img.resize((fit_w, fit_h), Image.LANCZOS) | |
| action = f"scaled down {orig_w}×{orig_h} → {fit_w}×{fit_h} (factor {scale:.4f})" | |
| else: | |
| # Image already fits — keep its native size; it will be centred with | |
| # extra padding on all sides. No upscaling. | |
| fit_w, fit_h = orig_w, orig_h | |
| action = f"kept original {fit_w}×{fit_h} (fits within safe zone)" | |
| # ── Build full-screen canvas ────────────────────────────────────────────── | |
| # The canvas is exactly screen_w × screen_h. When "Fit to Screen" maps this | |
| # 1-to-1 onto the display, the padding regions land precisely over the | |
| # menu bar and dock — image content is provably inside the safe zone. | |
| canvas_mode = "RGBA" if orig_mode == "RGBA" else "RGB" | |
| bg = (bg_color[0], bg_color[1], bg_color[2], 255) if canvas_mode == "RGBA" else bg_color | |
| canvas = Image.new(canvas_mode, (screen_w, screen_h), bg) | |
| # Centre horizontally on the screen; centre vertically within the safe zone. | |
| paste_x = (screen_w - fit_w) // 2 | |
| paste_y = top_pad + (inner_h - fit_h) // 2 | |
| actual_top = paste_y # guaranteed >= top_pad | |
| actual_bot = screen_h - paste_y - fit_h # guaranteed >= bot_pad | |
| # Flatten RGBA onto background before pasting into RGB canvas. | |
| if img.mode == "RGBA" and canvas_mode == "RGB": | |
| bg_layer = Image.new("RGBA", img.size, bg) | |
| bg_layer.paste(img, mask=img.split()[3]) | |
| img = bg_layer.convert("RGB") | |
| canvas.paste(img, (paste_x, paste_y)) | |
| # ── Save ────────────────────────────────────────────────────────────────── | |
| ext = src_path.suffix.lower() | |
| out_name = src_path.stem + suffix + src_path.suffix | |
| out_path = output_folder / out_name | |
| save_kwargs: dict = {} | |
| if ext in (".jpg", ".jpeg"): | |
| if canvas.mode == "RGBA": | |
| canvas = canvas.convert("RGB") | |
| save_kwargs = {"quality": 95, "subsampling": 0} | |
| elif ext == ".png": | |
| save_kwargs = {"optimize": True} | |
| canvas.save(out_path, **save_kwargs) | |
| # ── Console report ──────────────────────────────────────────────────────── | |
| print(f" {action}") | |
| print(f" Canvas : {screen_w} × {screen_h} px (full screen)") | |
| print(f" Image pasted : ({paste_x}, {paste_y}) → {fit_w}×{fit_h}") | |
| print(f" Actual padding: top={actual_top}px bottom={actual_bot}px " | |
| f"left/right={(screen_w - fit_w) // 2}px") | |
| print(f" → Saved: {out_name}") | |
| return out_path | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # Entry point | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def main(): | |
| args = parse_args() | |
| bg_color = parse_color(args.bg_color) | |
| if not args.input_folder.is_dir(): | |
| print(f"ERROR: Input folder not found: {args.input_folder}") | |
| sys.exit(1) | |
| args.output_folder.mkdir(parents=True, exist_ok=True) | |
| safe_h = args.screen_height - args.top_padding - args.bottom_padding | |
| print("=" * 66) | |
| print(" macOS Wallpaper Safe-Zone Processor") | |
| print("=" * 66) | |
| print(f" Screen : {args.screen_width} × {args.screen_height} px") | |
| print(f" Top padding : {args.top_padding} px ← always clear of menu bar") | |
| print(f" Bottom padding : {args.bottom_padding} px ← always clear of dock") | |
| print(f" Max content h : {safe_h} px") | |
| print(f" Output canvas : {args.screen_width} × {args.screen_height} px (full screen)") | |
| print(f" BG colour : RGB{bg_color}") | |
| print(f" Output suffix : '{args.suffix}'") | |
| print(f" Input folder : {args.input_folder.resolve()}") | |
| print(f" Output folder : {args.output_folder.resolve()}") | |
| print("=" * 66) | |
| print() | |
| print(" NOTE: EVERY image receives a full-screen canvas with padding,") | |
| print(" no matter its original size. No image is ever skipped.") | |
| print() | |
| ensure_pillow() | |
| images = sorted( | |
| p for p in args.input_folder.iterdir() | |
| if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS | |
| ) | |
| if not images: | |
| print(f"No supported images found in: {args.input_folder}") | |
| print(f"Supported formats: {', '.join(sorted(SUPPORTED_EXTENSIONS))}") | |
| sys.exit(0) | |
| print(f"Found {len(images)} image(s) to process.\n") | |
| ok = failed = 0 | |
| for i, src in enumerate(images, 1): | |
| print(f"[{i}/{len(images)}] {src.name}") | |
| try: | |
| process_image( | |
| src_path=src, | |
| output_folder=args.output_folder, | |
| screen_w=args.screen_width, | |
| screen_h=args.screen_height, | |
| top_pad=args.top_padding, | |
| bot_pad=args.bottom_padding, | |
| bg_color=bg_color, | |
| suffix=args.suffix, | |
| ) | |
| ok += 1 | |
| except Exception as exc: | |
| print(f" FAILED: {exc}") | |
| failed += 1 | |
| print() | |
| print("=" * 66) | |
| print(f" Done — {ok} succeeded, {failed} failed.") | |
| print(f" Output: {args.output_folder.resolve()}") | |
| print("=" * 66) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment