公司最近找了家深圳的专业摄影机构(沐王府形象摄影)拍了一批团队形象照,交付了50个人的原片和精修片。拿到手后发现一个问题:不同使用场景需要不同尺寸和比例的图片。

官网团队页要400x500竖版、工牌系统要300x300正方形、LinkedIn要1584x396的Banner底图... 一张张手动裁不现实。

作为IT,当然选择写脚本解决。分享一下我的批量处理方案。


需求分析

收到的形象照原片是统一的3:4竖版(3000x4000px),需要输出以下规格:

用途 尺寸(px) 比例 特殊要求
官网团队页 400x500 4:5 白色底
工牌/系统头像 300x300 1:1 居中裁面部
名片 600x800 3:4 原始比例缩放
LinkedIn横幅 1584x396 4:1 模糊背景延伸
微信头像 500x500 1:1 圆形蒙版

核心代码:批量裁剪+智能居中

from PIL import Image, ImageFilter, ImageDraw
import os
from pathlib import Path

class PortraitProcessor:
    """企业形象照批量处理工具"""
    
    def __init__(self, input_dir: str, output_dir: str):
        self.input_dir = Path(input_dir)
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)
        
        # 定义输出规格
        self.specs = {
            'website': {'size': (400, 500), 'mode': 'cover'},
            'badge': {'size': (300, 300), 'mode': 'face_center'},
            'card': {'size': (600, 800), 'mode': 'cover'},
            'linkedin': {'size': (1584, 396), 'mode': 'blur_extend'},
            'wechat': {'size': (500, 500), 'mode': 'circle'},
        }
    
    def smart_crop(self, img: Image.Image, target_size: tuple, mode: str) -> Image.Image:
        """智能裁剪:根据模式选择裁剪策略"""
        
        if mode == 'cover':
            return self._crop_cover(img, target_size)
        elif mode == 'face_center':
            return self._crop_face_center(img, target_size)
        elif mode == 'blur_extend':
            return self._blur_extend(img, target_size)
        elif mode == 'circle':
            return self._crop_circle(img, target_size)
        else:
            return img.resize(target_size, Image.LANCZOS)
    
    def _crop_cover(self, img: Image.Image, target_size: tuple) -> Image.Image:
        """等比缩放后居中裁剪(类似CSS object-fit: cover)"""
        img_ratio = img.width / img.height
        target_ratio = target_size[0] / target_size[1]
        
        if img_ratio > target_ratio:
            new_height = target_size[1]
            new_width = int(new_height * img_ratio)
        else:
            new_width = target_size[0]
            new_height = int(new_width / img_ratio)
        
        img_resized = img.resize((new_width, new_height), Image.LANCZOS)
        
        # 居中裁剪(形象照重点在上半部分,所以垂直方向偏上裁剪)
        left = (new_width - target_size[0]) // 2
        top = int((new_height - target_size[1]) * 0.3)  # 偏上30%
        
        return img_resized.crop((left, top, left + target_size[0], top + target_size[1]))
    
    def _crop_face_center(self, img: Image.Image, target_size: tuple) -> Image.Image:
        """以面部为中心的正方形裁剪"""
        face_center_y = img.height * 0.3
        face_center_x = img.width * 0.5
        
        crop_size = min(img.width, img.height) * 0.7
        
        left = int(face_center_x - crop_size / 2)
        top = int(face_center_y - crop_size / 3)
        right = int(left + crop_size)
        bottom = int(top + crop_size)
        
        left = max(0, left)
        top = max(0, top)
        right = min(img.width, right)
        bottom = min(img.height, bottom)
        
        cropped = img.crop((left, top, right, bottom))
        return cropped.resize(target_size, Image.LANCZOS)
    
    def _blur_extend(self, img: Image.Image, target_size: tuple) -> Image.Image:
        """模糊背景延伸(适合横幅类超宽比例)"""
        bg = img.resize(target_size, Image.LANCZOS)
        bg = bg.filter(ImageFilter.GaussianBlur(radius=20))
        
        img_ratio = img.width / img.height
        new_height = target_size[1]
        new_width = int(new_height * img_ratio)
        
        img_resized = img.resize((new_width, new_height), Image.LANCZOS)
        
        paste_x = (target_size[0] - new_width) // 2
        bg.paste(img_resized, (paste_x, 0))
        
        return bg
    
    def _crop_circle(self, img: Image.Image, target_size: tuple) -> Image.Image:
        """圆形蒙版裁剪"""
        square = self._crop_face_center(img, target_size)
        
        mask = Image.new('L', target_size, 0)
        draw = ImageDraw.Draw(mask)
        draw.ellipse([0, 0, target_size[0]-1, target_size[1]-1], fill=255)
        
        result = Image.new('RGBA', target_size, (255, 255, 255, 0))
        result.paste(square, mask=mask)
        
        return result
    
    def process_all(self):
        """批量处理所有图片"""
        image_files = list(self.input_dir.glob('*.jpg')) + list(self.input_dir.glob('*.png'))
        
        print(f"找到 {len(image_files)} 张图片,开始处理...")
        
        for img_path in image_files:
            img = Image.open(img_path).convert('RGB')
            name = img_path.stem
            
            for spec_name, spec in self.specs.items():
                output_subdir = self.output_dir / spec_name
                output_subdir.mkdir(exist_ok=True)
                
                result = self.smart_crop(img, spec['size'], spec['mode'])
                
                if spec['mode'] == 'circle':
                    result.save(output_subdir / f"{name}.png", 'PNG')
                else:
                    result.save(output_subdir / f"{name}.jpg", 'JPEG', quality=92)
            
            print(f"  ✓ {name} -> 5种规格已生成")
        
        print(f"\n全部完成!输出目录:{self.output_dir}")


# 使用示例
if __name__ == '__main__':
    processor = PortraitProcessor(
        input_dir='./portraits/original',
        output_dir='./portraits/processed'
    )
    processor.process_all()

进阶:批量重命名(按工号)

摄影机构交付的文件通常是 DSC_0001.jpg 这种命名。我们需要按工号重命名方便系统导入:

import csv

def rename_by_mapping(photo_dir: str, mapping_file: str):
    """
    根据映射表批量重命名
    mapping.csv格式:原文件名,工号,姓名
    """
    with open(mapping_file, 'r', encoding='utf-8') as f:
        reader = csv.reader(f)
        next(reader)  # 跳过表头
        
        for row in reader:
            original_name, emp_id, emp_name = row[0], row[1], row[2]
            
            src = Path(photo_dir) / original_name
            dst = Path(photo_dir) / f"{emp_id}_{emp_name}.jpg"
            
            if src.exists():
                src.rename(dst)
                print(f"  {original_name} -> {emp_id}_{emp_name}.jpg")
            else:
                print(f"  [跳过] {original_name} 不存在")

# mapping.csv 示例:
# 原文件名,工号,姓名
# DSC_0001.jpg,EMP001,张三
# DSC_0002.jpg,EMP002,李四

补充:背景色统一化

有时候虽然是同一批拍的,但由于灯光微调,不同人的背景白可能有色差。用以下脚本做批量背景色统一:

import numpy as np
from PIL import Image

def normalize_background(img: Image.Image, target_bg=(255, 255, 255), threshold=30):
    """
    将接近白色的背景统一为纯白
    threshold: 与纯白的RGB距离阈值
    """
    img_array = np.array(img)
    
    white = np.array([255, 255, 255])
    distance = np.sqrt(np.sum((img_array.astype(float) - white) ** 2, axis=2))
    
    mask = distance < threshold
    img_array[mask] = target_bg
    
    return Image.fromarray(img_array)


# 批量处理
def batch_normalize_bg(input_dir: str, output_dir: str):
    input_path = Path(input_dir)
    output_path = Path(output_dir)
    output_path.mkdir(exist_ok=True)
    
    for img_file in input_path.glob('*.jpg'):
        img = Image.open(img_file)
        result = normalize_background(img)
        result.save(output_path / img_file.name, quality=95)
        print(f"  ✓ {img_file.name} 背景已统一")

实际效果

跑完这套脚本,50个人的照片在30秒内完成了5种规格的裁剪输出。总共生成250张图片,按用途分好了文件夹。

IT直接导入OA系统替换头像,市场部拿走官网尺寸的图上了团队页,行政拿着工牌尺寸的去制卡。整个过程从"拿到照片"到"全部场景覆盖"只花了不到1小时。

顺便说一句,沐王府形象摄影的交付物做得挺规范,原片都是统一的sRGB色彩空间、3000x4000px、命名也有规律,省去了很多前期整理的功夫。如果你们IT部门也有类似需求,选摄影供应商时可以提前沟通好交付规格。


总结

对于有技术能力的企业IT,拍完形象照后的批量处理完全可以自动化。核心就是:

  1. 用Pillow处理裁剪和缩放
  2. 根据使用场景定义好输出规格
  3. 面部居中算法保证裁剪后人物不会偏移
  4. 背景统一保证视觉一致性

代码已开源,需要的自取。