先说说场景:我接手一个线上商城项目,每天要处理近千张商品图,原来运营团队靠PS一张张改,要么尺寸不对,要么水印位置跑偏,更离谱的是有个同事把JPEG格式的图保存成了BMP,导致用户加载一张图要等5秒。
领导丢给我一句话:”写个脚本,一劳永逸。”
*配图:批处理前后的对比效果图,左边是杂乱的图片命名和格式,右边是统一规范后的文件夹*
为什么选Pillow
0,.08);”
loading=”lazy” width=”800″ height=”500″>
Python处理图片的库不少:OpenCV功能强但太重,scikit-image偏科学计算。日常批量处理用Pillow(PIL的活跃分支)就够了,轻量、文档全、API符合直觉。
安装就一行:
“bash`
pip install Pillow
注意版本,我踩过一个坑:Pillow 8.0之后移除了对Python 3.5的支持,建议用Python 3.7+。之前一台老服务器上直接报"ImportError: DLL load failed",升到Python 3.9+解决。
第一个脚本:批量缩放
先看最刚需的场景。我运营那边要求:所有商品主图统一为800×800像素,小于这个尺寸的放大裁剪,大于的缩小居中裁剪。
这个设计真的反人类,因为商品图长宽比各不相同,直接resize会变形。我以前写过一段傻代码:
`python
错误示范:直接resize导致变形
from PIL import Image
img = Image.open("product.jpg")
img = img.resize((800, 800)) # 比例不对直接压扁
img.save("product_800.jpg")
`
后果是圆形的产品被压成椭圆,被运营追着骂了一星期。
正确做法是用thumbnail配合居中裁剪:
`python
import os
from PIL import Image
def batch_resize_crop(input_dir, output_dir, target_size=(800, 800)):
"""
批量缩放+居中裁剪
为什么要这么写:先等比缩放至最小覆盖尺寸,再居中裁剪,保证图不变形且不留白
"""
if not os.path.exists(output_dir):
os.makedirs(output_dir)
valid_ext = ('.jpg', '.jpeg', '.png', '.webp')
files = [f for f in os.listdir(input_dir) if f.lower().endswith(valid_ext)]
for i, filename in enumerate(files):
try:
img = Image.open(os.path.join(input_dir, filename))
# 转换为RGB模式(处理PNG透明通道)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# 计算缩放比例
w, h = img.size
ratio = max(target_size[0]/w, target_size[1]/h)
new_w = int(w * ratio)
new_h = int(h * ratio)
# 兼容写法:Pillow 9.0.0之前用Image.LANCZOS,之后用Image.Resampling.LANCZOS
resample_filter = getattr(Image, 'Resampling', Image).LANCZOS
img = img.resize((new_w, new_h), resample_filter)
# 居中裁剪
left = (new_w - target_size[0]) // 2
top = (new_h - target_size[1]) // 2
img = img.crop((left, top, left + target_size[0], top + target_size[1]))
output_path = os.path.join(output_dir, f"{i+1:04d}.jpg")
img.save(output_path, "JPEG", quality=90)
print(f"[OK] {filename} -> {output_path} | 原尺寸: {w}x{h} -> 新尺寸: {target_size[0]}x{target_size[1]}")
except Exception as e:
print(f"[ERROR] {filename} 处理失败: {e}")
if __name__ == "__main__":
batch_resize_crop("./raw_images", "./processed_images")
`
关键点:
- Image.Resampling.LANCZOS
是最高质量的缩放算法,比默认的NEAREST慢但效果好 - PNG转RGB时丢失透明度,如果有透明需求得单独处理(后面说)
- 文件名用04d
格式化,避免排序问题
另一个坑:内存泄漏
处理2000张图时,脚本在第800多张突然炸了。查了半小时,发现是线程池中没正确关闭文件句柄导致的。
官方文档这段文档不够清晰:"PLIOpenMP support is experimental"。翻译成人话:多线程下Pillow可能会把内存吃到爆。
解决方案是手动控制并发数量,并在worker里关闭文件:
`python
from concurrent.futures import ThreadPoolExecutor, as_completed
import gc
def worker(img_path, output_dir):
"""
单个图片处理函数
为什么要这么写:避免在主线程持有大对象,让GC能正常回收
"""
try:
img = Image.open(img_path)
# 显式读取像素数据到内存
img.load()
# ...(缩放裁剪逻辑同上方)
img.close() # 关闭文件句柄,防止资源泄漏
img = None # 主动释放
gc.collect()
return True
except Exception as e:
return f"{img_path}: {e}"
控制线程数为4,防止PIL的OpenMP搞事情
with ThreadPoolExecutor(max_workers=4) as executor:
futures = []
for img_path in img_paths:
futures.append(executor.submit(worker, img_path, output_dir))
for future in as_completed(futures):
result = future.result()
if result is not True:
print(f"[ERROR] {result}")
`
从原来单线程每秒处理12张图,提升到每秒处理48张(4核CPU上),内存稳定在150MB以下。这种优化直接让处理时间从45分钟缩到12分钟。
*配图:性能对比图表,左边是12张/秒,右边是48张/秒*
批量加水印
运营需求又来了:所有图片左上角加品牌logo,右下角加防伪水印。
Logo水印的坑在于:白底图片上白色logo看不见。需要根据背景颜色自动调整logo亮度。
`python
def auto_contrast_watermark(watermark_img, bg_color):
"""
自动调整水印对比度
为什么要这么写:应对不同背景色的图片,保证水印可见
"""
from PIL import ImageEnhance
# 计算背景亮度
brightness = (0.299 * bg_color[0] + 0.587 * bg_color[1] + 0.114 * bg_color[2])
# 如果背景亮,水印变暗;背景暗,水印变亮
if brightness > 128:
enhancer = ImageEnhance.Brightness(watermark_img)
watermark_img = enhancer.enhance(0.3) # 降低亮度
else:
enhancer = ImageEnhance.Brightness(watermark_img)
watermark_img = enhancer.enhance(1.7) # 提高亮度
return watermark_img
def add_watermarks(input_dir, output_dir, logo_path, text="版权所有"):
"""
批量添加水印
"""
logo = Image.open(logo_path).convert("RGBA")
logo = logo.resize((100, 40), getattr(Image, 'Resampling', Image).LANCZOS) # 调整logo大小
# 创建文字水印,如果字体文件不存在则用默认字体
try:
font = ImageFont.truetype("msyh.ttc", 24) # 微软雅黑,Linux下路径可能不同
except OSError:
font = ImageFont.load_default() # 回退到默认字体
for filename in os.listdir(input_dir):
if not filename.lower().endswith(('.jpg', '.png')):
continue
img = Image.open(os.path.join(input_dir, filename)).convert("RGBA")
# 获取背景色(取图片边缘像素平均色)
bg_pixels = []
for x in range(0, img.width, 10):
for y in [0, img.height - 1]:
bg_pixels.append(img.getpixel((x, y)))
bg_color = tuple(sum(c) // len(c) for c in zip(*bg_pixels))
# 调整logo对比度
adjusted_logo = auto_contrast_watermark(logo, bg_color)
# 粘贴logo到左上角
img.paste(adjusted_logo, (10, 10), adjusted_logo)
# 添加文字水印到右下角
draw = ImageDraw.Draw(img)
text_bbox = draw.textbbox((0, 0), text, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = img.width - text_width - 10
text_y = img.height - text_height - 10
draw.text((text_x, text_y), text, font=font, fill=(255, 255, 255, 128))
output_path = os.path.join(output_dir, f"watermarked_{filename}")
img.save(output_path, "PNG")
img.close()
print(f"[OK] {filename} -> {output_path}")
if __name__ == "__main__":
add_watermarks("./processed_images", "./watermarked_images", "logo.png")
`
格式检测与转换
最后补一个实用功能:自动检测图片真实格式并转换。有些图片后缀是.jpg,实际是PNG,导致浏览器解析出错。
`python
import imghdr
def detect_and_convert(input_dir, output_dir, target_format="JPEG"):
"""
检测图片真实格式并转换
为什么要这么写:防止后缀名与实际格式不一致导致加载问题
"""
if not os.path.exists(output_dir):
os.makedirs(output_dir)
for filename in os.listdir(input_dir):
filepath = os.path.join(input_dir, filename)
if not os.path.isfile(filepath):
continue
# 检测真实格式
real_format = imghdr.what(filepath)
if real_format is None:
print(f"[WARN] {filename}: 无法检测格式,跳过")
continue
# 如果格式不匹配或需要转换
ext = os.path.splitext(filename)[1].lower()
if real_format.upper() != target_format or ext not in ['.jpg', '.jpeg']:
try:
img = Image.open(filepath)
# 转换到目标格式
if img.mode in ('RGBA', 'P') and target_format == "JPEG":
img = img.convert('RGB')
new_filename = os.path.splitext(filename)[0] + f".{target_format.lower()}"
output_path = os.path.join(output_dir, new_filename)
img.save(output_path, target_format, quality=90)
img.close()
print(f"[OK] {filename} ({real_format}) -> {new_filename} ({target_format})")
except Exception as e:
print(f"[ERROR] {filename} 转换失败: {e}")
else:
# 格式正确,直接复制
shutil.copy2(filepath, os.path.join(output_dir, filename))
print(f"[SKIP] {filename}: 格式正确,直接复制")
if __name__ == "__main__":
detect_and_convert("./raw_images", "./converted_images", target_format="JPEG")
“
总结
折腾一天下来,几个血泪教训:
现在这个脚本每天自动跑,运营那边再也没找过我麻烦。领导还夸我”技术赋能业务”,其实就是把重复劳动自动化了。