Last active
July 27, 2026 15:49
-
-
Save ShawnHymel/9b9dd4d44d5049f24adcedd5eb91c524 to your computer and use it in GitHub Desktop.
Batch Image Converter
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 | |
| """ | |
| Batch image converter / resizer. | |
| Version: 1.0 | |
| Author: Shawn Hymel (OK, mostly Claude) | |
| Date: July 27, 2026 | |
| Usage: | |
| Converts a list of image files to a target format, optionally resizing them, | |
| and writes each result next to the original (or into --outdir) using the same | |
| root name with the new extension. | |
| You just need PIL (pillow): | |
| python -m pip install pillow | |
| Examples | |
| -------- | |
| # Just convert to PNG, keep resolution | |
| ./convert_images.py *.jpg --format PNG | |
| # Scale to 800px wide, height follows aspect ratio | |
| ./convert_images.py photo.tif --format JPG --width 800 | |
| # Force exact dimensions (aspect ratio is not preserved) | |
| ./convert_images.py *.png --format WEBP --width 512 --height 512 | |
| # Non-interactive: never clobber anything | |
| ./convert_images.py *.png --format PNG --on-conflict ignore | |
| # Example with folder | |
| python convert_images.py "Blog Post/"*.png -W 1000 -f jpg | |
| License: Zero-Clause BSD | |
| Permission to use, copy, modify, and/or distribute this software for | |
| any purpose with or without fee is hereby granted. | |
| THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL | |
| WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES | |
| OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE | |
| FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY | |
| DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN | |
| AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT | |
| OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import glob as globmod | |
| import sys | |
| from pathlib import Path | |
| from PIL import Image, ImageOps | |
| # --------------------------------------------------------------------------- # | |
| # Format helpers | |
| # Friendly aliases -> canonical PIL format names. | |
| FORMAT_ALIASES = { | |
| "JPG": "JPEG", | |
| "JPE": "JPEG", | |
| "TIF": "TIFF", | |
| "J2K": "JPEG2000", | |
| "JP2": "JPEG2000", | |
| } | |
| # Preferred file extension per format (PIL's registry sometimes picks odd ones). | |
| PREFERRED_EXT = { | |
| "JPEG": ".jpg", | |
| "TIFF": ".tif", | |
| "JPEG2000": ".jp2", | |
| "PPM": ".ppm", | |
| } | |
| # Formats with no alpha channel: RGBA/LA/P images must be flattened first. | |
| NO_ALPHA_FORMATS = {"JPEG", "JPEG2000", "BMP", "PPM", "EPS", "PCX"} | |
| def resolve_format(name: str) -> str: | |
| """Normalise a user-supplied format name to a PIL format PIL can save.""" | |
| fmt = FORMAT_ALIASES.get(name.strip().upper(), name.strip().upper()) | |
| Image.init() # populate the codec registries | |
| if fmt not in Image.SAVE: | |
| supported = ", ".join(sorted(Image.SAVE)) | |
| raise argparse.ArgumentTypeError( | |
| f"cannot save format {name!r}. Supported: {supported}" | |
| ) | |
| return fmt | |
| def extension_for(fmt: str) -> str: | |
| if fmt in PREFERRED_EXT: | |
| return PREFERRED_EXT[fmt] | |
| Image.init() | |
| for ext, registered in Image.registered_extensions().items(): | |
| if registered == fmt: | |
| return ext | |
| return "." + fmt.lower() | |
| # --------------------------------------------------------------------------- # | |
| # Geometry | |
| def target_size(size: tuple[int, int], width: int | None, height: int | None): | |
| """Return the new (w, h), or None if no resize is needed. | |
| - neither given -> keep original resolution | |
| - one given -> scale to it, preserving aspect ratio | |
| - both given -> use exactly those values (aspect ratio may change) | |
| """ | |
| w0, h0 = size | |
| if width is None and height is None: | |
| return None | |
| if width is not None and height is not None: | |
| new = (width, height) | |
| elif width is not None: | |
| new = (width, max(1, round(h0 * width / w0))) | |
| else: | |
| new = (max(1, round(w0 * height / h0)), height) | |
| return None if new == size else new | |
| # --------------------------------------------------------------------------- # | |
| # Conflict handling | |
| def unique_path(path: Path) -> Path: | |
| """First free variant of `path`: name-1.ext, name-2.ext, ...""" | |
| n = 1 | |
| while True: | |
| candidate = path.with_name(f"{path.stem}-{n}{path.suffix}") | |
| if not candidate.exists(): | |
| return candidate | |
| n += 1 | |
| def ask_conflict(src: Path, dst: Path) -> Path | None: | |
| """Prompt the user. Returns the path to write to, or None to skip.""" | |
| same = dst.resolve() == src.resolve() | |
| what = "the source file itself" if same else "an existing file" | |
| print(f"\n{dst} is {what}.", file=sys.stderr) | |
| while True: | |
| try: | |
| choice = input(" [o]verwrite / [i]gnore / [r]ename? ").strip().lower() | |
| except EOFError: | |
| print(" (no input available - ignoring)", file=sys.stderr) | |
| return None | |
| if choice in ("o", "overwrite"): | |
| return dst | |
| if choice in ("i", "ignore", "s", "skip", ""): | |
| return None | |
| if choice in ("r", "rename"): | |
| suggestion = unique_path(dst) | |
| answer = input(f" new name [{suggestion.name}]: ").strip() | |
| new = dst.with_name(answer) if answer else suggestion | |
| if new.exists(): | |
| print(f" {new} also exists - pick again.", file=sys.stderr) | |
| continue | |
| return new | |
| print(" Please answer o, i, or r.", file=sys.stderr) | |
| def resolve_conflict(src: Path, dst: Path, policy: str) -> Path | None: | |
| if not dst.exists(): | |
| return dst | |
| if policy == "ask": | |
| return ask_conflict(src, dst) | |
| if policy == "overwrite": | |
| return dst | |
| if policy == "rename": | |
| return unique_path(dst) | |
| return None # ignore | |
| # --------------------------------------------------------------------------- # | |
| # Conversion | |
| def prepare_for_save(im: Image.Image, fmt: str, background: str) -> Image.Image: | |
| """Flatten / convert modes that the target format can't represent.""" | |
| if fmt in NO_ALPHA_FORMATS: | |
| if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info): | |
| rgba = im.convert("RGBA") | |
| flat = Image.new("RGB", rgba.size, background) | |
| flat.paste(rgba, mask=rgba.split()[-1]) | |
| return flat | |
| if im.mode not in ("RGB", "L", "CMYK"): | |
| return im.convert("RGB") | |
| elif im.mode == "P" and fmt != "GIF": | |
| return im.convert("RGBA" if "transparency" in im.info else "RGB") | |
| return im | |
| def convert_one(src: Path, args, fmt: str, ext: str) -> bool: | |
| """Convert a single file. Returns True if something was written.""" | |
| try: | |
| with Image.open(src) as im: | |
| im.load() | |
| if args.autorotate: | |
| im = ImageOps.exif_transpose(im) | |
| new_size = target_size(im.size, args.width, args.height) | |
| old_size = im.size | |
| if new_size: | |
| im = im.resize(new_size, Image.Resampling.LANCZOS) | |
| out_dir = Path(args.outdir) if args.outdir else src.parent | |
| dst = out_dir / (src.stem + ext) | |
| dst = resolve_conflict(src, dst, args.on_conflict) | |
| if dst is None: | |
| print(f"skip {src}") | |
| return False | |
| out = prepare_for_save(im, fmt, args.background) | |
| save_kwargs = {} | |
| if fmt == "JPEG": | |
| save_kwargs.update(quality=args.quality, optimize=True, | |
| progressive=True) | |
| elif fmt == "WEBP": | |
| save_kwargs.update(quality=args.quality) | |
| elif fmt == "PNG": | |
| save_kwargs.update(optimize=True) | |
| if args.dry_run: | |
| print(f"[dry] {src} -> {dst} {old_size} -> {out.size}") | |
| return False | |
| dst.parent.mkdir(parents=True, exist_ok=True) | |
| out.save(dst, fmt, **save_kwargs) | |
| print(f"ok {src} -> {dst} {old_size} -> {out.size}") | |
| return True | |
| except (OSError, ValueError) as exc: | |
| print(f"error {src}: {exc}", file=sys.stderr) | |
| return False | |
| # --------------------------------------------------------------------------- # | |
| # Input expansion | |
| GLOB_CHARS = "*?[" | |
| def caseless_pattern(pattern: str) -> str: | |
| """Rewrite a glob so letters match either case: '*.png' -> '*.[pP][nN][gG]'. | |
| Existing bracket expressions are left alone so things like [0-9] survive. | |
| """ | |
| out, in_bracket = [], False | |
| for ch in pattern: | |
| if in_bracket: | |
| out.append(ch) | |
| if ch == "]": | |
| in_bracket = False | |
| elif ch == "[": | |
| out.append(ch) | |
| in_bracket = True | |
| elif ch.isalpha(): | |
| out.append(f"[{ch.lower()}{ch.upper()}]") | |
| else: | |
| out.append(ch) | |
| return "".join(out) | |
| def expand_inputs(patterns, ignore_case: bool = False): | |
| """Turn CLI arguments into a de-duplicated list of existing files. | |
| Anything that already names a real path is passed through untouched (this | |
| is the normal case on Unix, where the shell has already globbed). Anything | |
| that doesn't exist but looks like a pattern is expanded here, which is what | |
| makes quoted patterns and Windows shells work. '**' recurses. | |
| """ | |
| files, seen, missing = [], set(), [] | |
| def add(path: Path): | |
| key = str(path.resolve()) | |
| if key not in seen: | |
| seen.add(key) | |
| files.append(path) | |
| for pattern in patterns: | |
| p = Path(pattern) | |
| if p.exists(): | |
| add(p) | |
| continue | |
| if not any(ch in pattern for ch in GLOB_CHARS): | |
| missing.append(pattern) | |
| continue | |
| expanded = caseless_pattern(pattern) if ignore_case else pattern | |
| matches = sorted(globmod.glob(expanded, recursive=True)) | |
| matches = [Path(m) for m in matches if Path(m).is_file()] | |
| if matches: | |
| for m in matches: | |
| add(m) | |
| else: | |
| missing.append(pattern) | |
| return files, missing | |
| # --------------------------------------------------------------------------- # | |
| # CLI | |
| def parse_args(argv=None): | |
| p = argparse.ArgumentParser( | |
| description="Convert and optionally resize a batch of images.", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| p.add_argument("files", nargs="+", metavar="FILE", | |
| help="input image files, or glob patterns such as '*.png' " | |
| "(quote them so the script does the expanding)") | |
| p.add_argument("-i", "--ignore-case", action="store_true", | |
| help="match glob patterns case-insensitively, so '*.PNG' " | |
| "also finds .png files (only affects patterns this " | |
| "script expands, not ones your shell already did)") | |
| p.add_argument("-f", "--format", required=True, type=resolve_format, | |
| help="output format, e.g. PNG, JPG, WEBP, TIFF") | |
| p.add_argument("-W", "--width", type=int, help="target width in pixels") | |
| p.add_argument("-H", "--height", type=int, help="target height in pixels") | |
| p.add_argument("-o", "--outdir", help="write results here instead of " | |
| "beside the originals") | |
| p.add_argument("-c", "--on-conflict", default="ask", | |
| choices=["ask", "overwrite", "ignore", "rename"], | |
| help="what to do when the output path already exists") | |
| p.add_argument("-q", "--quality", type=int, default=90, | |
| help="quality for lossy formats (JPEG/WEBP)") | |
| p.add_argument("--background", default="white", | |
| help="fill colour when flattening transparency") | |
| p.add_argument("--no-autorotate", dest="autorotate", action="store_false", | |
| help="do not apply the EXIF orientation tag") | |
| p.add_argument("-n", "--dry-run", action="store_true", | |
| help="show what would happen, write nothing") | |
| args = p.parse_args(argv) | |
| for name, val in (("width", args.width), ("height", args.height)): | |
| if val is not None and val < 1: | |
| p.error(f"--{name} must be a positive integer") | |
| return args | |
| # --------------------------------------------------------------------------- # | |
| # Main | |
| def main(argv=None) -> int: | |
| args = parse_args(argv) | |
| fmt = args.format | |
| ext = extension_for(fmt) | |
| sources, missing = expand_inputs(args.files, args.ignore_case) | |
| for pattern in missing: | |
| print(f"error {pattern}: no such file or no matches", file=sys.stderr) | |
| if not sources: | |
| print("nothing to do.", file=sys.stderr) | |
| return 1 | |
| written = 0 | |
| for src in sources: | |
| if not src.is_file(): | |
| print(f"error {src}: not a file", file=sys.stderr) | |
| continue | |
| written += convert_one(src, args, fmt, ext) | |
| print(f"\n{written} file(s) written.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment