"""Turns a folder of your own photos into wallpapers the launcher can show on a small TV. python tools/wallpaper_prep.py ~/Pictures/trip /tmp/my-theme/wallpapers Every JPEG, PNG, WebP and (with pillow-heif) HEIC file in the first folder is centre-cropped to 16:9, shrunk to 1280x720, darkened so the clock, the grid and the dock stay readable, and saved as 01.jpg, 02.jpg, ... in the second folder. The originals are never touched: a target that is the source folder (by any spelling or link), or an 01.jpg there that is a symlink or hard link to one of your photos, stops the run before anything is written. Each picture is kept under --max-kb (300 by default, what themes/README.md asks for) by lowering the JPEG quality a step at a time. The darkening is painted into the picture, so wallpaper_dim can stay at 0 and the launcher draws no shade layer. The numbers that worked on a Xiaomi Mi Box S (2 GB) are the defaults: brightness 0.25 and a gradient that adds up to 60% black toward the bottom, where the dock sits. Needs Pillow; HEIC (iPhone and many Android cameras) also needs pillow-heif: python -m pip install -r tools/requirements-photos.txt Use pictures you have the right to show. """ import argparse import os import sys EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"} def parse_size(text): try: w, h = text.lower().split("x") w, h = int(w), int(h) except ValueError: raise argparse.ArgumentTypeError("size must look like 1280x720") if w < 16 or h < 9 or w > 7680 or h > 4320: raise argparse.ArgumentTypeError("size must be between 16x9 and 7680x4320") return w, h def source_files(folder): """Picture files directly inside folder, in name order (so the numbering is stable).""" names = sorted(n for n in os.listdir(folder) if os.path.splitext(n)[1].lower() in EXTENSIONS) return [os.path.join(folder, n) for n in names if os.path.isfile(os.path.join(folder, n))] def overlapping_outputs(files, target): """Names of the numbered outputs in target that already are, or link to, one of the source files. A symlink or hardlink called 01.jpg in the target would make the save overwrite an original, and one pointing at a later photo would do it before that photo's turn, so every planned name is checked against every source file up front, by (device, inode), before anything is written. """ identities = {(info.st_dev, info.st_ino) for info in map(os.stat, files)} clashes = [] for number in range(1, len(files) + 1): name = "%02d.jpg" % number try: info = os.stat(os.path.join(target, name)) except OSError: continue if (info.st_dev, info.st_ino) in identities: clashes.append(name) return clashes def darken(image, brightness, gradient): """Scale brightness, then blend toward black from top (0) to bottom (gradient).""" from PIL import Image, ImageEnhance image = ImageEnhance.Brightness(image).enhance(brightness) if gradient > 0: mask = Image.linear_gradient("L").resize(image.size).point(lambda v: int(v * gradient)) image = Image.composite(Image.new("RGB", image.size, (0, 0, 0)), image, mask) return image def prepare(path, out_path, size, brightness, gradient, quality, max_kb): """Writes one wallpaper and returns (size in KB, JPEG quality used).""" from PIL import Image, ImageOps with Image.open(path) as opened: image = ImageOps.exif_transpose(opened).convert("RGB") image = ImageOps.fit(image, size, Image.LANCZOS, centering=(0.5, 0.5)) image = darken(image, brightness, gradient) q = quality while True: image.save(out_path, "JPEG", quality=q, optimize=True) kb = os.path.getsize(out_path) / 1024 if kb <= max_kb or q <= 50: return round(kb), q q -= 6 def main(argv=None): parser = argparse.ArgumentParser(description="Resize and darken photos for the launcher's wallpaper folder.") parser.add_argument("source", help="folder with your photos") parser.add_argument("target", help="folder to write 01.jpg, 02.jpg, ... into (created if missing)") parser.add_argument("--size", type=parse_size, default=(1280, 720), help="default 1280x720") parser.add_argument("--brightness", type=float, default=0.25, help="0.1 to 1.0, default 0.25") parser.add_argument("--gradient", type=float, default=0.6, help="extra black toward the bottom, 0 to 0.9, default 0.6") parser.add_argument("--quality", type=int, default=84, help="starting JPEG quality, default 84") parser.add_argument("--max-kb", type=int, default=300, help="size limit per picture, default 300") args = parser.parse_args(argv) if not 0.1 <= args.brightness <= 1.0: parser.error("--brightness must be between 0.1 and 1.0") if not 0 <= args.gradient <= 0.9: parser.error("--gradient must be between 0 and 0.9") if not 30 <= args.quality <= 95: parser.error("--quality must be between 30 and 95") if not os.path.isdir(args.source): parser.error(f"{args.source} is not a folder") source_real = os.path.realpath(args.source) target_real = os.path.realpath(args.target) if target_real == source_real or target_real.startswith(source_real.rstrip(os.sep) + os.sep): parser.error("the target must be a different folder, and not inside the source: the originals are never overwritten") if os.path.exists(args.target) and os.path.samefile(args.source, args.target): parser.error("the target must be a different folder, and not inside the source: the originals are never overwritten") try: import PIL # noqa: F401 except ImportError: sys.exit("Pillow is missing: python -m pip install -r tools/requirements-photos.txt") try: import pillow_heif pillow_heif.register_heif_opener() except ImportError: pillow_heif = None files = source_files(args.source) if not files: sys.exit(f"no JPEG, PNG, WebP or HEIC pictures in {args.source}") clashes = overlapping_outputs(files, args.target) if clashes: sys.exit(f"{', '.join(clashes)} in {args.target} would overwrite your original photos " "(a link or the same file); remove or rename them, or pick another target. Nothing was written.") os.makedirs(args.target, exist_ok=True) written = 0 for number, path in enumerate(files, 1): name = os.path.basename(path) if os.path.splitext(name)[1].lower() in {".heic", ".heif"} and pillow_heif is None: print(f"skipped {name}: HEIC needs pillow-heif (python -m pip install -r tools/requirements-photos.txt)") continue out_path = os.path.join(args.target, "%02d.jpg" % number) try: kb, quality = prepare(path, out_path, args.size, args.brightness, args.gradient, args.quality, args.max_kb) except Exception as error: # a corrupt picture must not stop the rest print(f"skipped {name}: {error}") continue written += 1 note = "" if kb <= args.max_kb else f" (over {args.max_kb} KB even at quality {quality})" print(f"{os.path.basename(out_path)} <- {name} {kb} KB, quality {quality}{note}") print(f"{written} of {len(files)} pictures written to {args.target}") return 0 if written else 1 if __name__ == "__main__": sys.exit(main())