装修现场照片一天几十张,月底要给业主出"施工日记",真正麻烦的不是拍照,而是两点:照片看不出拍摄日期,几百张图散在目录里没法翻。下面这套纯 Python + Pillow 的本地小工具就解决这两件事——批量在照片右下角叠加「日期 + 工种」水印,再按 3×3 拼成日报图,全程不联网、双击即用。文中三个坑(中文路径、EXIF 旋转、中文水印乱码)都是实战里最容易卡住批处理流程的地方。
一、执行流程与依赖
整个脚本只做四步:扫描目标目录下的 .jpg/.jpeg/.png;读取拍摄时间(优先 EXIF,取不到就退回文件修改时间);在图片右下角叠加半透明文字水印(日期 + 自定义工种标签);把当天的照片每 9 张拼成一张九宫格,输出到 日报/ 目录。
依赖只有一个:
本文示例代码在 Python 3.11 + Pillow 10 上实测通过,字体路径需按本机实际情况调整。
二、坑一:中文路径不能靠字符串硬拼
Windows 下路径带中文(例如 D:/装修照片/水电/)时,最稳的写法是全程使用 pathlib.Path,它内部通过 os.fspath 做正确转码,避免 open() 抛 FileNotFoundError: [Errno 2]。
- from pathlib import Path
- root = Path(r"D:/装修照片/水电")
- imgs = sorted(p for p in root.glob("*") if p.suffix.lower() in (".jpg", ".jpeg", ".png"))
复制代码
反例是用 os.listdir() 拿到文件名再手动拼字符串,这种写法在 open 阶段极易因为 GBK 编码问题直接炸掉。用 Path 一步到位,还能顺便用 suffix 做后缀过滤、用 sorted 保证处理顺序稳定。
三、坑二:EXIF Orientation 导致图片"横着读"
很多手机照片在 Windows 看图里方向正常,Pillow 打开却是旋转 90° 的。原因是旋转信息存在 EXIF 的 Orientation 标签里,看图软件会自动纠正,而 Pillow 默认不纠正。批量处理前必须统一处理:
- from PIL import Image, ExifTags
- def fix_orientation(img):
- try:
- exif = img._getexif()
- if not exif:
- return img
- for k, v in exif.items():
- if ExifTags.TAGS.get(k) == "Orientation":
- if v == 3:
- img = img.rotate(180, expand=True)
- elif v == 6:
- img = img.rotate(-90, expand=True)
- elif v == 8:
- img = img.rotate(90, expand=True)
- except Exception:
- pass
- return img
复制代码
这里对应的是三个常见的 Orientation 取值:3 表示需要转 180°,6 需要顺时针转 90°(即 rotate(-90)),8 需要逆时针转 90°(即 rotate(90)),expand=True 保证旋转后画布跟着扩展,不会被裁掉边角。异常整体吞掉是为了让个别 EXIF 损坏的图片不至于中断整批任务。
关键顺序:纠正必须放在 resize / thumbnail 之前。不纠正直接拼图,九宫格里会有一半图片是歪的。
四、坑三:中文水印乱码,必须指定字体文件
Pillow 的 ImageDraw.text 默认字体不带中文字形,直接写中文会输出方块。必须显式加载一个中文字体 ttf/ttc:
- from PIL import ImageDraw, ImageFont
- font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 36) # 微软雅黑
复制代码
两个细节:一是 truetype 的字体路径同样可能含中文,用 Path 传入即可,不要手工拼接反斜杠;二是字号要跟着图片尺寸走,别把像素值写死,否则大图看不清、小图盖满画面。
叠加水印的函数如下,思路是先画一条半透明黑底条,再在上面写白字:
- def add_watermark(img, text):
- draw = ImageDraw.Draw(img, "RGBA")
- w, h = img.size
- # 半透明黑底条
- draw.rectangle([(w-360, h-60), (w, h)], fill=(0, 0, 0, 120))
- draw.text((w-350, h-48), text, font=font, fill=(255, 255, 255, 230))
- return img
复制代码
ImageDraw.Draw(img, "RGBA") 是让 120 / 230 这类 alpha 值生效的前提,如果底层图是 RGB 模式,半透明会被直接丢掉。另外原文明确提醒:水印要打在缩略图上而不是原图画质上,既看得清又不破坏原图。落到函数组织上,就是把 add_watermark 放到 thumbnail 之后调用;此时注意矩形偏移量(w-360、h-60、w-350、h-48)是按缩略图尺寸调的固定值,需要与实际 thumb 尺寸对齐,否则文字会顶出画面。
五、九宫格拼图
把若干缩略图按 3 列贴到一张灰色底图上:
- def make_grid(images, cols=3, thumb=360, gap=8):
- rows = (len(images) + cols - 1) // cols
- grid = Image.new("RGB", (cols*thumb + (cols+1)*gap, rows*thumb + (rows+1)*gap), (240,240,240))
- for i, im in enumerate(images):
- im = fix_orientation(im).convert("RGB")
- im.thumbnail((thumb, thumb))
- r, c = divmod(i, cols)
- x = gap + c*(thumb+gap)
- y = gap + r*(thumb+gap)
- grid.paste(im, (x, y))
- return grid
复制代码
几个参数含义:cols 是列数(默认 3,即九宫格);thumb 是单元格最大边长,thumbnail 会等比例缩放且不拉伸变形,比手动 resize 安全得多;gap 是格子间距,同时参与画布尺寸计算(多留一格,保证四周也有留白)。行数用 (len+cols-1)//cols 向上取整,这样不足 9 张也不会出错。convert("RGB") 在粘贴前执行,避免 RGBA 或 P 模式图片粘到 RGB 画布上出现混色问题。
六、按天分组生成日报
用拍摄日期做分组键,每天输出一张日报图:
- from collections import defaultdict
- from datetime import datetime
- groups = defaultdict(list)
- for p in imgs:
- d = get_shoot_date(p) # 读 EXIF 拍摄时间,取不到退回文件 mtime
- groups[d.strftime("%Y-%m-%d")].append(p)
- for day, paths in groups.items():
- day_imgs = [add_watermark(fix_orientation(Image.open(p)), f"{day} 水电") for p in paths[:9]]
- grid = make_grid(day_imgs)
- grid.save(out_dir / f"{day}_施工日记.png")
复制代码
get_shoot_date 的实现要点是:先尝试从 EXIF 中取拍摄时间(DateTimeOriginal 一类标签),解析失败或标签不存在时,用文件状态的修改时间(st_mtime)兜底,两者统一转成 datetime 再格式化。paths[:9] 截断保证单张日报最多 9 格,超出的照片应另起一张图或按批次切分。输出文件名用 Path 拼接,避免再次踩中文路径的坑。
七、避坑清单
1)路径一律用 pathlib.Path,不要 os.listdir() 拼字符串,绕开中文编码雷区;
2)开图先 fix_orientation 纠正 EXIF 旋转,再做缩放和拼图,顺序不能反;
3)中文水印必须用 ImageFont.truetype 指定中文字体文件,否则输出方块乱码;字号按图片尺寸缩放,不写死像素;
4)水印打在缩略图上,原图保持干净;半透明填充要求 Draw 使用 "RGBA" 模式;
5)拼图用 thumbnail 等比缩放,比硬编码 resize 稳,不会出现拉伸变形;
6)单张日报固定 9 格时记得切片,避免一天几十张图全塞进一张画布。
把这套脚本跑起来,每天收工前几十秒就能生成一张可直接发给业主的施工日报,比零散发图专业得多。真正需要改动的通常只有三处:照片根目录、字体文件路径、以及拼接在水印里的工种标签文本。 |