Skip to content

Instantly share code, notes, and snippets.

@ShawnHymel
Last active July 27, 2026 15:49
Show Gist options
  • Select an option

  • Save ShawnHymel/9b9dd4d44d5049f24adcedd5eb91c524 to your computer and use it in GitHub Desktop.

Select an option

Save ShawnHymel/9b9dd4d44d5049f24adcedd5eb91c524 to your computer and use it in GitHub Desktop.
Batch Image Converter
#!/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