Files
meme-api/resize_images_max_side.py

290 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
将「最长边」超过指定值的图片等比缩小,使最长边等于该值;不超过的不改动。
默认扫描 原图/、默认目标边长 300 像素、默认 10 线程并行。依赖 Pillow。
处理完成后若只改了 原图/,可再运行 npm run build 同步 public/meme。
"""
from __future__ import annotations
import argparse
import sys
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
try:
from PIL import Image, ImageSequence
except ImportError as exc: # pragma: no cover
raise SystemExit(
"This script requires Pillow. Install it with: python3 -m pip install pillow"
) from exc
IMAGE_EXTENSIONS = {
".avif",
".bmp",
".gif",
".jfif",
".jpeg",
".jpg",
".jpe",
".png",
".tif",
".tiff",
".webp",
}
DEFAULT_ROOT = Path("原图")
DEFAULT_JOBS = 10
_log_lock = threading.Lock()
def _log(*args, file=sys.stdout, **kwargs) -> None:
with _log_lock:
print(*args, file=file, **kwargs)
def is_image_path(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
def is_multi_frame(im: Image.Image) -> bool:
return bool(
getattr(im, "is_animated", False) or getattr(im, "n_frames", 1) > 1
)
def target_size(w: int, h: int, max_side: int) -> tuple[int, int] | None:
if max(w, h) <= max_side:
return None
if w >= h:
nw = max_side
nh = max(round(h * max_side / w), 1)
else:
nh = max_side
nw = max(round(w * max_side / h), 1)
return nw, nh
def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
return im.convert("RGBA").resize(size, Image.Resampling.LANCZOS)
def static_save_format(path: Path, im: Image.Image) -> tuple[str, dict]:
ext = path.suffix.lower()
fmt = (im.format or "").upper()
if ext in (".jpg", ".jpeg", ".jpe") or fmt in ("JPEG", "MPO"):
return "JPEG", {"quality": 90, "optimize": True}
if ext == ".png" or fmt == "PNG":
return "PNG", {"optimize": True}
if ext == ".webp" or fmt == "WEBP":
return "WEBP", {"quality": 85, "method": 6}
if ext == ".gif" or fmt == "GIF":
return "GIF", {}
if ext in (".bmp",) or fmt == "BMP":
return "BMP", {}
if ext in (".tif", ".tiff") or fmt == "TIFF":
return "TIFF", {"compression": "tiff_lzw"}
if ext == ".avif" or fmt == "AVIF":
return "AVIF", {"quality": 80}
# fallback: keep extension hint
if ext == ".webp":
return "WEBP", {"quality": 85, "method": 6}
return "PNG", {"optimize": True}
def process_static(im: Image.Image, path: Path, max_side: int, dry_run: bool) -> bool:
w, h = im.size
th = target_size(w, h, max_side)
if th is None:
return False
if dry_run:
_log(f"[dry-run] would resize {path} {w}x{h} -> {th[0]}x{th[1]}")
return True
out = im.resize(th, Image.Resampling.LANCZOS)
fmt, kwargs = static_save_format(path, im)
if fmt == "JPEG" and out.mode in ("RGBA", "P"):
out = out.convert("RGB")
elif fmt == "JPEG" and out.mode not in ("RGB", "L"):
out = out.convert("RGB")
_atomic_save(path, lambda p: out.save(p, format=fmt, **kwargs))
_log(f"resized {path} {w}x{h} -> {th[0]}x{th[1]}")
return True
def _atomic_save(path: Path, saver) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
suffix=path.suffix, delete=False, dir=path.parent
) as tmp:
tmp_path = Path(tmp.name)
try:
saver(tmp_path)
tmp_path.replace(path)
finally:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
def process_animated(im: Image.Image, path: Path, max_side: int, dry_run: bool) -> bool:
w, h = im.size
th = target_size(w, h, max_side)
if th is None:
return False
loop = int(im.info.get("loop", 0) or 0)
frames: list[Image.Image] = []
durations: list[int] = []
src_format = (im.format or "").upper()
for frame in ImageSequence.Iterator(im):
rgba = resize_rgba(frame, th)
frames.append(rgba)
d = frame.info.get("duration", im.info.get("duration", 100))
try:
durations.append(int(d) if d is not None else 100)
except (TypeError, ValueError):
durations.append(100)
if len(frames) == 0:
return False
if len(durations) < len(frames):
durations.extend([durations[-1]] * (len(frames) - len(durations)))
if dry_run:
_log(f"[dry-run] would resize animated {path} {w}x{h} -> {th[0]}x{th[1]}")
return True
def save_gif(p: Path) -> None:
if len(frames) == 1:
frames[0].save(p, format="GIF", duration=durations[0], loop=loop)
else:
frames[0].save(
p,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=loop,
disposal=2,
)
def save_webp(p: Path) -> None:
frames[0].save(
p,
format="WEBP",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=loop,
quality=85,
method=6,
)
if src_format == "GIF" or path.suffix.lower() == ".gif":
_atomic_save(path, save_gif)
else:
_atomic_save(path, save_webp)
_log(f"resized animated {path} {w}x{h} -> {th[0]}x{th[1]}")
return True
def process_file(path: Path, max_side: int, dry_run: bool) -> bool:
try:
with Image.open(path) as im:
im.load()
if is_multi_frame(im):
fmt = (im.format or "").upper()
ext = path.suffix.lower()
if fmt not in ("GIF", "WEBP") and ext not in (".gif", ".webp"):
_log(
f"skip animated (only GIF/WebP): {path}",
file=sys.stderr,
)
return False
return process_animated(im, path, max_side, dry_run)
return process_static(im, path, max_side, dry_run)
except OSError as e:
_log(f"skip (read error) {path}: {e}", file=sys.stderr)
return False
def iter_image_files(root: Path) -> list[Path]:
out: list[Path] = []
if not root.is_dir():
return out
for p in root.rglob("*"):
if is_image_path(p):
out.append(p)
return sorted(out)
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
description=(
"Downscale images whose longest side exceeds --max (default 300px); "
"smaller images unchanged. In-place under the given root."
)
)
parser.add_argument(
"root",
nargs="?",
default=DEFAULT_ROOT,
type=Path,
help=f"Root directory (default: {DEFAULT_ROOT})",
)
parser.add_argument(
"--max",
type=int,
default=300,
metavar="PX",
help="Target length for the longest side in pixels (default: 300)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Only print what would be resized",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=DEFAULT_JOBS,
metavar="N",
help=f"Parallel worker threads (default: {DEFAULT_JOBS})",
)
args = parser.parse_args(argv)
root: Path = args.root
max_side: int = args.max
if max_side < 1:
print("--max must be >= 1", file=sys.stderr)
return 2
workers = max(1, args.jobs)
files = iter_image_files(root)
if not files:
print(f"no images under {root}", file=sys.stderr)
return 1
n = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
for touched in pool.map(
lambda p: process_file(p, max_side, args.dry_run), files
):
if touched:
n += 1
print(f"done. touched {n} file(s) under {root} ({workers} workers)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))