图像批量压缩与水印
Pillow 批量缩放、压缩质量与添加文字 / 图片水印,节省存储空间。
在线演示
0处理张数
0 B输入总大小
0 B输出总大小
0%体积变化
等待操作:点“生成示例图片”可直接体验,或选择你本地的图片再点“批量处理”。
项目简介
产品图、公众号配图、摄影原片,批量处理是刚需。本页用 Pillow 实现批量压缩与水印:统一缩放最大边、按质量压缩 JPEG / WebP、叠加文字水印或 Logo,最后统计节省空间比例,适合接入图片上传流水线。
代码示例:批量压缩与水印
py
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
MAX_EDGE = 1600
QUALITY = 82
WATERMARK_TEXT = "nocau.com"
def process_one(src: Path, out_dir: Path) -> int:
img = Image.open(src)
img = img.convert("RGB")
# 等比缩放:最长边不超过 MAX_EDGE
if max(img.size) > MAX_EDGE:
img.thumbnail((MAX_EDGE, MAX_EDGE), Image.LANCZOS)
# 右下角文字水印(可换为 Image.paste 贴 Logo)
draw = ImageDraw.Draw(img)
font = ImageFont.truetype("arial.ttf", 28)
w, h = draw.textbbox((0, 0), WATERMARK_TEXT, font=font)[2:]
px, py = img.width - w - 18, img.height - h - 18
draw.rectangle([px - 8, py - 6, px + w + 8, py + h + 6], fill=(0, 0, 0, 160))
draw.text((px, py), WATERMARK_TEXT, fill=(255, 255, 255), font=font)
# 输出 JPEG 质量压缩
out = out_dir / (src.stem + "_wm.jpg")
img.save(out, "JPEG", quality=QUALITY, optimize=True)
return src.stat().st_size - out.stat().st_size
def batch(root: Path, out_root: Path) -> None:
out_root.mkdir(parents=True, exist_ok=True)
saved = 0
for p in root.glob("*.{jpg,jpeg,png,webp}"):
saved += process_one(p, out_root)
print(f"saved {saved / 1024 / 1024:.1f} MB")操作步骤
pip install Pillow后运行python image_batch.py 输入目录 输出目录。- 调整
MAX_EDGE/QUALITY权衡体积与画质;透明 PNG 建议输出 WebP 保留 alpha。 - 水印文字也可改用 Logo 图片:
img.paste(logo, (px, py), logo)。
完整可运行工程
需装 PillowPython 3.8+
完整工程(非片段):batch_image.py 打通「收集 → 计算目标尺寸 → 转换 → 体积统计」全流程,并带 --dry-run 预演与 --selftest 自检,不是只贴一段 resize() 片段。
image-batch/
├── batch_image.py # 收集 / 缩放 / 灰度 / 转格式 / 统计
├── test_batch_image.py # 最小用例回归
├── requirements.txt # Pillow
└── README.md
├── batch_image.py # 收集 / 缩放 / 灰度 / 转格式 / 统计
├── test_batch_image.py # 最小用例回归
├── requirements.txt # Pillow
└── README.md
batch_image.py
核心实现:collect / plan_size / process / selftest,含 --dry-run。
python
#!/usr/bin/env python3
"""批量图片处理:缩放 / 灰度 / 格式转换 / 体积统计(依赖 Pillow)
用法:
python batch_image.py --src ./in --out ./out --width 800 --format webp --quality 82
python batch_image.py --src ./in --out ./out --width 800 --gray --dry-run
python batch_image.py --src ./in --out ./out --json
python batch_image.py --selftest
"""
import argparse
import json
import sys
from pathlib import Path
try:
from PIL import Image
except ImportError: # 给出可执行的修复指引,而不是抛栈
print("缺少依赖 Pillow:请先执行 python -m pip install -r requirements.txt", file=sys.stderr)
raise SystemExit(3)
SUFFIX = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tiff"}
FMT_EXT = {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "webp": ".webp"}
def collect(src, recursive=False):
p = Path(src)
if not p.exists():
raise SystemExit("输入路径不存在:%s" % p)
if p.is_file():
return [p]
it = p.rglob("*") if recursive else p.glob("*")
return sorted(f for f in it if f.is_file() and f.suffix.lower() in SUFFIX)
def plan_size(w, h, tw, th):
"""0 表示该维度自适应,保持宽高比;两者都为 0 则尺寸不变。"""
if not tw and not th:
return w, h
if tw and not th:
return tw, max(1, round(h * tw / w))
if not tw and th:
return max(1, round(w * th / h)), th
return tw, th
def process(path, out_dir, width=0, height=0, gray=False, fmt=None, quality=82, dry=False):
with Image.open(path) as im:
im.load()
src_fmt = (im.format or "").lower()
src_mode = im.mode
tw, th = plan_size(im.width, im.height, width, height)
out_fmt = (fmt or src_fmt or "png").lower()
if out_fmt == "jpg":
out_fmt = "jpeg"
ext = FMT_EXT.get(out_fmt, ".png")
target = Path(out_dir) / (path.stem + ext)
rec = {
"file": str(path), "out": str(target), "format": out_fmt,
"src_size": [im.width, im.height], "out_size": [tw, th],
"src_bytes": path.stat().st_size, "gray": bool(gray), "dry_run": bool(dry),
}
if dry:
return rec
work = im.convert("L") if gray else im.convert("RGB") if out_fmt == "jpeg" else im
if (tw, th) != (im.width, im.height):
work = work.resize((tw, th), Image.LANCZOS)
target.parent.mkdir(parents=True, exist_ok=True)
save_kw = {}
if out_fmt in ("jpeg", "webp"):
save_kw["quality"] = quality
if out_fmt == "webp":
save_kw["method"] = 6
work.save(target, out_fmt.upper() if out_fmt != "jpeg" else "JPEG", **save_kw)
rec["out_bytes"] = target.stat().st_size
rec["saved_ratio"] = round(1 - rec["out_bytes"] / rec["src_bytes"], 4) if rec["src_bytes"] else 0.0
rec["src_mode"] = src_mode
return rec
def selftest():
import tempfile
with tempfile.TemporaryDirectory() as td:
td = Path(td)
src = td / "in"; out = td / "out"; src.mkdir()
Image.new("RGB", (1200, 600), (30, 90, 220)).save(src / "a.png")
Image.new("RGB", (640, 480), (220, 60, 60)).save(src / "b.jpg", quality=95)
assert plan_size(1200, 600, 800, 0) == (800, 400) # 等比缩放
assert plan_size(1200, 600, 0, 0) == (1200, 600) # 不缩放
r = process(src / "a.png", out, width=800, quality=82)
assert r["out_size"] == [800, 400], r
with Image.open(r["out"]) as im:
assert im.size == (800, 400) and im.mode == "RGB"
r2 = process(src / "b.jpg", out, width=320, gray=True, fmt="webp", quality=60)
with Image.open(r2["out"]) as im:
assert im.size == (320, 240), im.size
assert im.format == "WEBP", im.format
px = im.convert("RGB").getpixel((10, 10))
assert px[0] == px[1] == px[2], px # WebP 走 lossy 时会落成 RGB,但像素须为灰
assert r2["out_bytes"] > 0
r2b = process(src / "b.jpg", out, width=320, gray=True, fmt="png")
with Image.open(r2b["out"]) as im:
assert im.mode == "L", im.mode # PNG 支持 8 位灰度,应保留 L 模式
r3 = process(src / "a.png", td / "out_dry", width=100, dry=True)
assert r3["dry_run"] and not Path(r3["out"]).exists()
assert len(collect(src)) == 2
print("selftest OK:等比缩放 / 灰度 / 格式转换 / dry-run 不改盘 用例通过")
def main(argv=None):
ap = argparse.ArgumentParser(description="批量图片处理(缩放 / 灰度 / 格式转换)")
ap.add_argument("--src", required=False, help="源文件或目录")
ap.add_argument("--out", help="输出目录(默认 ./out)")
ap.add_argument("--width", type=int, default=0, help="目标宽度,0 = 自适应")
ap.add_argument("--height", type=int, default=0, help="目标高度,0 = 自适应")
ap.add_argument("--gray", action="store_true", help="转灰度")
ap.add_argument("--format", choices=["jpeg", "jpg", "png", "webp"], help="输出格式,默认保持原格式")
ap.add_argument("--quality", type=int, default=82, help="JPEG/WebP 质量,默认 82")
ap.add_argument("--recursive", action="store_true", help="递归处理子目录")
ap.add_argument("--dry-run", action="store_true", help="只输出处理计划,不写盘")
ap.add_argument("--json", action="store_true")
ap.add_argument("--selftest", action="store_true")
a = ap.parse_args(argv)
if a.selftest:
selftest()
return 0
if not a.src:
ap.error("请给出 --src")
files = collect(a.src, a.recursive)
if not files:
raise SystemExit("没有找到图片文件(支持 %s)。" % ", ".join(sorted(SUFFIX)))
results = [process(f, a.out or "./out", a.width, a.height, a.gray, a.format, a.quality, a.dry_run) for f in files]
if a.json:
print(json.dumps(results, ensure_ascii=False, indent=2))
return 0
total_in = sum(r["src_bytes"] for r in results)
total_out = sum(r.get("out_bytes", 0) for r in results)
print("%s %d 张图片 → %s" % ("计划处理" if a.dry_run else "已处理", len(results), a.out or "./out"))
for r in results:
line = " %s %dx%d → %dx%d %s" % (Path(r["file"]).name, r["src_size"][0], r["src_size"][1],
r["out_size"][0], r["out_size"][1], r["format"])
if not a.dry_run:
line += " %d → %d 字节(省 %.1f%%)" % (r["src_bytes"], r["out_bytes"], r["saved_ratio"] * 100)
print(line)
if not a.dry_run and total_in:
print("合计 %d → %d 字节,整体变化 %.1f%%" % (total_in, total_out, (total_out - total_in) / total_in * 100))
return 0
if __name__ == "__main__":
sys.exit(main())
test_batch_image.py
unittest 回归:等比缩放、灰度与 WebP、dry-run 不落盘、质量与体积关系。
python
"""batch_image.py 最小用例回归(标准库 unittest + Pillow)"""
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from batch_image import collect, plan_size, process
class TestBatchImage(unittest.TestCase):
def setUp(self):
self.td = tempfile.TemporaryDirectory()
self.src = Path(self.td.name) / "in"
self.out = Path(self.td.name) / "out"
self.src.mkdir()
Image.new("RGB", (1000, 500), (10, 120, 200)).save(self.src / "wide.png")
Image.new("RGB", (300, 300), (200, 40, 40)).save(self.src / "square.jpg", quality=95)
def tearDown(self):
self.td.cleanup()
def test_plan_size_keeps_ratio(self):
self.assertEqual(plan_size(1000, 500, 400, 0), (400, 200))
self.assertEqual(plan_size(1000, 500, 0, 250), (500, 250))
self.assertEqual(plan_size(1000, 500, 0, 0), (1000, 500))
def test_resize_writes_target_size(self):
r = process(self.src / "wide.png", self.out, width=400)
with Image.open(r["out"]) as im:
self.assertEqual(im.size, (400, 200))
def test_gray_and_webp(self):
r = process(self.src / "square.jpg", self.out, width=150, gray=True, fmt="webp", quality=60)
with Image.open(r["out"]) as im:
self.assertEqual(im.format, "WEBP")
self.assertEqual(im.size, (150, 150))
px = im.convert("RGB").getpixel((5, 5))
self.assertEqual(px[0], px[1]) # WebP lossy 落成 RGB,但像素应为灰
self.assertEqual(px[1], px[2])
def test_gray_png_keeps_l_mode(self):
r = process(self.src / "square.jpg", self.out, width=150, gray=True, fmt="png")
with Image.open(r["out"]) as im:
self.assertEqual(im.mode, "L")
def test_dry_run_does_not_write(self):
r = process(self.src / "wide.png", self.out, width=100, dry=True)
self.assertTrue(r["dry_run"])
self.assertFalse(Path(r["out"]).exists())
def test_collect_filters_images(self):
(self.src / "note.txt").write_text("x", encoding="utf-8")
names = sorted(p.name for p in collect(self.src))
self.assertEqual(names, ["square.jpg", "wide.png"])
def test_quality_affects_size(self):
lo = process(self.src / "wide.png", self.out / "lo", fmt="jpeg", quality=40)
hi = process(self.src / "wide.png", self.out / "hi", fmt="jpeg", quality=95)
self.assertLess(lo["out_bytes"], hi["out_bytes"])
if __name__ == "__main__":
unittest.main(verbosity=2)
requirements.txt
依赖清单:Pillow>=10.0。
text
Pillow>=10.0
README.md
尺寸规则表、格式与质量说明、安全约定。
markdown
# 批量图片处理(image-batch)
把一个目录里的图片一次性缩放 / 转灰度 / 换格式,并统计体积变化。基于 Pillow,逻辑与网页上的在线演示一致。
## 文件说明
| 文件 | 作用 |
|---|---|
| `batch_image.py` | 收集 / 计算目标尺寸 / 转换 / 统计 + CLI 入口 |
| `test_batch_image.py` | unittest 最小用例回归 |
| `requirements.txt` | 依赖清单(Pillow) |
## 安装与运行
```bash
python -m pip install -r requirements.txt
python batch_image.py --src ./in --out ./out --width 800 --format webp --quality 82
python batch_image.py --src ./in --out ./out --width 800 --gray --quality 80
python batch_image.py --src ./in --out ./out --width 1200 --dry-run # 只看计划,不写盘
python batch_image.py --src ./in --out ./out --json
python batch_image.py --selftest
python test_batch_image.py
```
## 尺寸规则
| `--width` | `--height` | 结果 |
|---|---|---|
| 800 | 0 | 宽固定 800,高按原比例自适应 |
| 0 | 400 | 高固定 400,宽按原比例自适应 |
| 800 | 600 | 强制 800×600(可能变形) |
| 0 | 0 | 尺寸不变,仅换格式 / 灰度 |
## 输出格式与体积
- `--format` 不传时保持原格式(`jpg` 会归一为 `jpeg` 保存)。
- `--quality` 只对 JPEG / WebP 生效,默认 82;WebP 额外使用 `method=6` 以取得更小体积。
- 灰度会输出 `L` 模式(8 位单通道),体积通常下降明显。
- 每张图都会打印「原始字节 → 输出字节(省 x%)」,末尾给出整体变化率。
## 安全约定
- `--dry-run` 只输出计划、不写任何文件;
- 输出目录不存在时自动创建,文件名沿用原文件名(仅换扩展名),**不会覆盖源目录中的文件**(除非 `--out` 指向源目录本身)。
运行标注
- 可直接运行(需先装依赖):
python -m pip install -r requirements.txt,然后python batch_image.py --src ./in --out ./out --width 800 --format webp。 - 需先改哪些配置项:无需改代码。
--src(源目录/文件)与--out(输出目录)是必填语义;尺寸用--width/--height,格式用--format,质量用--quality。 - 首次跑建议加
--dry-run预览计划,确认输出路径无误后再正式执行。 - 本页在线演示与工程区脚本处理同一类任务(缩放 / 灰度 / 格式转换),但演示跑在浏览器 Canvas、脚本跑在 Pillow,二者参数语义一致。
- 自测:
python batch_image.py --selftest(或python test_batch_image.py),6 个用例应全部通过。