本文记录一个桌面端图片水印工具的完整实现思路。文章不讨论具体业务场景,只聚焦技术本身:透明图层合成、中文排版与自动换行、交互式裁切、双缓冲自绘,以及几个真实的跨版本兼容坑。
技术栈:Python 3.13 / wxPython 4.2(GUI)/ Pillow 11(图像处理)
阅读建议:适合有一点 Python 基础、想入门桌面端图像编程的开发者。
"C:\Users\86182\Desktop\身份证上做标记\id_card_watermark.py"

一、需求与技术选型
工具要完成的事情可以拆成四步:
- 打开一张本地图片;
- 在输入框中填写用途等信息,自动生成一段完整的标签文字;
- 标签以半透明形式覆盖在图片上,支持拖动位置、调整字号 / 透明度 / 角度 / 颜色,支持平铺;
- 将标签与原图合成,导出为新的图片文件。
选型上:
| 库 | 负责什么 | 为什么选它 |
|---|---|---|
| Pillow | 所有像素级操作:画字、旋转、裁切、合成 | Python 图像领域事实标准,API 简洁,纯本地处理 |
| wxPython | 桌面窗口、控件、鼠标事件、自绘 | 原生控件渲染,事件模型成熟,跨平台且性能足够 |
一个重要的设计约束是:图片处理全部在本地完成,程序不发起任何网络请求。
二、整体架构
代码刻意分成三层,让"图像算法"与"界面交互"互不耦合:
┌────────────────────────────────────────────────┐
│ MainFrame (主窗口) │
│ 控件布局 / 参数管理 / 文件对话框 / 事件路由 │
├────────────────────────────────────────────────┤
│ PreviewPanel (预览面板) │
│ 缩放显示 / 双缓冲自绘 / 鼠标拖动 / 裁切选框 │
├────────────────────────────────────────────────┤
│ 纯 Pillow 合成层(无任何 GUI 依赖) │
│ load_chinese_font / make_label_image │
│ _wrap_text / apply_watermark │
└────────────────────────────────────────────────┘
这个分层带来三个直接好处:
- 合成函数可以脱离界面单独调用和单元测试(在没有显示器的环境里也能跑);
- 预览和导出共用同一个
apply_watermark(),从根本上避免"预览看着没问题、导出来不一样"; - 界面层只管"参数从哪来、结果往哪画",逻辑很薄。
程序中"当前工作图"始终保留原始分辨率(self.orig_image),旋转、裁切都是在它上面做非破坏性的内存变换,直到点"导出"才写磁盘。
三、核心代码详解
3.1 中文字体加载:多候选 + 逐级回退
Pillow 默认的位图字体不支持中文,必须显式加载 TrueType 字体。直接硬编码字体路径有两个风险:系统字体目录可能不在 C 盘、字体文件可能不存在。因此从环境变量取系统目录,并准备候选列表:
python
def load_chinese_font(size_px):
"""加载系统中的中文字体,按常用字体依次回退。"""
win_dir = os.environ.get("WINDIR", r"C:\Windows")
candidates = ["msyh.ttc", "msyhbd.ttc", "simhei.ttf", "simsun.ttc"]
for name in candidates:
path = os.path.join(win_dir, "Fonts", name)
if os.path.exists(path):
try:
return ImageFont.truetype(path, max(1, int(size_px)))
except OSError:
continue # 文件存在但损坏时尝试下一个
return ImageFont.load_default()
几个细节:
max(1, ...)保证字号不会传 0 或负数导致异常;- 候选列表把"微软雅黑 → 黑体 → 宋体"按现代程度排序,失败了还有兜底;
load_default()是最后防线------即使渲染不出中文,程序也不会崩溃。
3.2 文字测量:textbbox 与原点偏移
要把文字画在一张"刚好包住它"的透明画布上,得先测量文字尺寸。Pillow 的 textbbox() 返回一个四元组 (x0, y0, x1, y1),注意左上角不一定是 (0, 0) ------很多字体在设计上有留白和侧轴承,x0/y0 可能为负值或正值。
因此绘制时不能直接 draw.text((pad, pad), ...),而要把字体度量的偏移补回来:
python
measure = ImageDraw.Draw(Image.new("RGBA", (8, 8)))
bbox = measure.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
...
# 把文字的"逻辑原点"对齐到 (pad, pad)
draw.text((pad - bbox[0], pad - bbox[1]), text, font=font, fill=fill)
这里 measure 只用于测量,给它一张 8×8 的临时图即可------textbbox 不依赖画布大小。
3.3 多行排版与中文逐字自动换行
当标签文字很长(几十个字)时,必须换行。中文文本没有空格,不能像英文那样按单词切分,所以采用逐字贪心策略:不断尝试往当前行加一个字,一旦超宽就把当前行锁定、从这个字开始新的一行。
python
def _wrap_text(text, font, measure, max_width):
"""按字符逐字换行(适合中文);文字中的显式换行符也照常断行。"""
if not max_width:
return text.split("\n")
lines = []
for paragraph in text.split("\n"):
cur = ""
for ch in paragraph:
trial = cur + ch
b = measure.textbbox((0, 0), trial, font=font)
if b[2] - b[0] > max_width and cur:
lines.append(cur)
cur = ch
else:
cur = trial
lines.append(cur)
return lines or [""]
有了行列表,再统一计算画布尺寸并逐行绘制。行高取各行 textbbox 高度的最大值,行间距设为字号的 25%:
python
def make_label_image(text, font_px, color, opacity, max_width=None):
font = load_chinese_font(font_px)
pad = max(8, int(font_px * 0.5)) # 四周留白,给旋转留余量
measure = ImageDraw.Draw(Image.new("RGBA", (8, 8)))
lines = _wrap_text(text, font, measure, max_width)
boxes = [measure.textbbox((0, 0), ln, font=font) for ln in lines]
line_h = max(b[3] - b[1] for b in boxes)
gap = int(font_px * 0.25)
tw = max(b[2] - b[0] for b in boxes)
th = line_h * len(lines) + gap * (len(lines) - 1)
label = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0))
draw = ImageDraw.Draw(label)
alpha = int(max(0.0, min(1.0, opacity)) * 255) # 0~1 → 0~255,并夹紧范围
fill = (color[0], color[1], color[2], alpha)
y = 0
for ln, b in zip(lines, boxes):
draw.text((pad - b[0], pad + y - b[1]), ln, font=font, fill=fill)
y += line_h + gap
return label
注意 pad 的双重作用:既防止多行文字贴边,也为下一步的旋转变换预留空间。
3.4 RGBA 透明合成:两阶段图层模型
整个工具的灵魂是 alpha 通道。合成流程分两阶段:
python
def apply_watermark(base_img, text, font_px, angle, opacity, color,
tiled=False, center=(0.5, 0.5)):
base = base_img.convert("RGBA")
width, height = base.size
# 单条标签按图片宽度自动换行;平铺模式不换行,保持每个水印小而完整
max_width = None if tiled else int(width * 0.78)
label = make_label_image(text, font_px, color, opacity, max_width)
if angle:
label = label.rotate(angle, expand=True, resample=Image.BICUBIC)
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
# ... 把一个或多个 label 贴到 overlay 上 ...
return Image.alpha_composite(base, overlay)
为什么不直接把文字画在原图上,而要先画到一张透明 overlay?
- 文字本身的透明度和
overlay的透明度是两件事------overlay让定位、平铺、旋转后的放置可以反复调整,不污染底图; Image.alpha_composite()要求两张图同尺寸、同模式,用它做最终合成可以得到正确的抗锯齿边缘;- 将来要加 logo 水印、边框等元素,只需往
overlay上继续贴。
3.5 旋转:expand=True 是关键
python
label = label.rotate(angle, expand=True, resample=Image.BICUBIC)
两个参数都有讲究:
expand=True:旋转后自动扩大画布以容纳完整图像。不加它,一张矩形图转 30° 后四个角会被齐齐切掉;resample=Image.BICUBIC:双三次重采样,斜着的笔画边缘比默认的最近邻平滑得多。
由于旋转后标签尺寸变了,放置时的居中计算必须用旋转后 的 label.width / label.height。
3.6 平铺水印:交错行 + 负坐标起铺
平铺不是简单的双层循环。两个细节决定成败:
- 从负坐标开始铺,保证图片最左、最上边缘被完整的水印覆盖,而不是被裁掉一半的水印;
- 奇数行横向偏移半个步长,形成菱形交错,视觉上更难被裁切去除。
python
step_x = int(label.width * 0.95)
step_y = int(label.height * 1.15)
row = 0
y = -label.height
while y < height:
offset = (step_x // 2) if row % 2 else 0
x = -label.width + offset - step_x // 3
while x < width:
overlay.alpha_composite(label, (x, y))
x += step_x
y += step_y
row += 1
单条模式则按归一化中心点放置:
python
cx = int(center[0] * width)
cy = int(center[1] * height)
overlay.alpha_composite(label, (cx - label.width // 2, cy - label.height // 2))
3.7 归一化参数模型:"所见即所得"的数学保证
这是整个工程中最值得说的设计。所有与尺寸相关的参数都不存绝对值,只存比例:
python
self.params = {
"font_ratio": 0.05, # 字号 = 当前图片宽度的 5%
"opacity": 0.35,
"angle": -25,
"color": (220, 0, 0),
"tiled": False,
"center": (0.5, 0.5), # 标签中心在图片中的归一化位置
}
预览时,图片被缩小到面板大小,像素字号按缩略图宽度 换算;导出时,按原图宽度换算:
python
# 预览路径
font_px = max(8, int(p["font_ratio"] * prev_w))
# 导出路径
font_px = max(8, int(p["font_ratio"] * orig_w))
center 也是同理:(0.5, 0.5) 在任何分辨率下都表示图片中心。这样无论预览图怎么缩放,同一套参数喂给同一个 apply_watermark(),得到的布局在几何上严格一致------所见即所得不是靠"仔细对齐"实现的,而是靠坐标系统的设计保证的。
3.8 预览缩放管线
预览面板每次刷新时,先算缩放比、再生成缩略图、最后走合成管线:
python
def update_preview(self):
if self.frame.orig_image is None:
self.bmp = None
self.Refresh()
return
panel_w, panel_h = self.GetClientSize()
if panel_w < 10 or panel_h < 10: # 窗口尚未布局完成
return
orig_w, orig_h = self.frame.orig_image.size
self.scale = min(panel_w / orig_w, panel_h / orig_h, 1.0)
prev_w = max(1, int(orig_w * self.scale))
prev_h = max(1, int(orig_h * self.scale))
thumb = self.frame.orig_image.resize((prev_w, prev_h), Image.LANCZOS)
p = self.frame.params
result = apply_watermark(
thumb,
text=self.frame.label_text(),
font_px=max(8, int(p["font_ratio"] * prev_w)),
angle=p["angle"], opacity=p["opacity"], color=p["color"],
tiled=p["tiled"], center=p["center"],
)
self.bmp = pil_to_wx_bitmap(result)
self.ox = (panel_w - prev_w) // 2 # 记录图片在面板中的偏移
self.oy = (panel_h - prev_h) // 2
self.Refresh()
要点:
- 缩放比取
min(宽方向, 高方向, 1.0):完整显示图片且绝不放大,避免小图被插值糊掉; - 缩放用
Image.LANCZOS(高质量下采样滤波器),缩小照片时边缘更锐利; ox/oy是图片在面板上的绘制偏移,后续鼠标坐标换算全靠它。
Pillow 图像转 wxPython 位图用零拷贝的字节缓冲:
python
def pil_to_wx_bitmap(pil_img):
rgb = pil_img.convert("RGB")
w, h = rgb.size
return wx.Bitmap.FromBuffer(w, h, rgb.tobytes())
3.9 wxPython 双缓冲自绘
在面板上画图要响应 EVT_PAINT。直接在 PaintDC 上绘制,窗口刷新频繁时会有明显闪烁,所以使用双缓冲:
python
def __init__(self, parent, frame):
super().__init__(parent)
self.frame = frame
self.SetBackgroundStyle(wx.BG_STYLE_PAINT) # 使用 AutoBufferedPaintDC 的前提
...
def on_paint(self, event):
dc = wx.AutoBufferedPaintDC(self)
dc.SetBackground(wx.Brush(self.GetBackgroundColour()))
dc.Clear()
if self.bmp is None:
dc.SetTextForeground(wx.Colour(120, 120, 120))
dc.SetFont(wx.Font(13, wx.FONTFAMILY_DEFAULT,
wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL))
dc.DrawText("请先打开一张图片", 24, 28)
return
dc.DrawBitmap(self.bmp, self.ox, self.oy)
坑预警 :
AutoBufferedPaintDC在构造时会断言窗口背景样式必须是wx.BG_STYLE_PAINT,否则直接抛 C++ 断言错误。这两行必须配套出现。
非平铺模式下,程序还在标签中心绘制一个虚线圆圈加十字,作为"这里可以拖动"的视觉提示:
python
if not p["tiled"]:
w, h = self.bmp.GetSize()
cx = self.ox + int(p["center"][0] * w)
cy = self.oy + int(p["center"][1] * h)
dc.SetPen(wx.Pen(wx.Colour(0, 120, 215), 1, PEN_STYLE_DOT))
dc.SetBrush(wx.TRANSPARENT_BRUSH)
dc.DrawCircle(cx, cy, 8)
dc.DrawLine(cx - 12, cy, cx + 12, cy)
dc.DrawLine(cx, cy - 12, cx, cy + 12)
裁切选框的绘制考虑了"在任何底色上都要看得见":白色虚线框打底、红色实线框叠上、四角再加实心手柄:
python
def _draw_crop_rect(self, dc):
x0, y0, x1, y1 = self.crop_rect
w, h = self.bmp.GetSize()
px0 = self.ox + int(min(x0, x1) * w)
py0 = self.oy + int(min(y0, y1) * h)
px1 = self.ox + int(max(x0, x1) * w)
py1 = self.oy + int(max(y0, y1) * h)
dc.SetBrush(wx.TRANSPARENT_BRUSH)
dc.SetPen(wx.Pen(wx.Colour(255, 255, 255), 2, PEN_STYLE_DASH))
dc.DrawRectangle(px0, py0, px1 - px0, py1 - py0)
dc.SetPen(wx.Pen(wx.Colour(230, 0, 0), 1, wx.PENSTYLE_SOLID))
dc.DrawRectangle(px0, py0, px1 - px0, py1 - py0)
dc.SetBrush(wx.Brush(wx.Colour(230, 0, 0)))
s = 4
for hx, hy in ((px0, py0), (px1, py0), (px0, py1), (px1, py1)):
dc.DrawRectangle(hx - s, hy - s, s * 2, s * 2)
所有坐标都经过 min/max 归一化处理,所以用户反方向拖鼠标也没关系。
3.10 鼠标交互:CaptureMouse 与事件路由
鼠标坐标需要从"面板像素"换算成"图片归一化坐标",并夹紧到 [0, 1]:
python
def _to_ratio(self, pos):
if self.bmp is None:
return None
w, h = self.bmp.GetSize()
rx = (pos[0] - self.ox) / w
ry = (pos[1] - self.oy) / h
return max(0.0, min(1.0, rx)), max(0.0, min(1.0, ry))
同一片预览区要承载两种交互(拖水印 / 画裁切框),靠 crop_mode 标志在按下时路由:
python
def on_left_down(self, event):
if self.bmp is None:
return
ratio = self._to_ratio(event.GetPosition())
if ratio is None:
return
if self.frame.crop_mode:
self.crop_dragging = True
# 起点:零面积矩形
self.crop_rect = (ratio[0], ratio[1], ratio[0], ratio[1])
self.CaptureMouse()
self.frame.on_crop_rect_changed(False)
self.Refresh()
elif not self.frame.params["tiled"]:
self.frame.params["center"] = ratio
self.dragging = True
self.CaptureMouse()
self.update_preview()
移动事件里同样分流。裁切框拖动只需要 Refresh() 重绘(选框是纯界面元素),拖动水印则要重跑合成管线:
python
def on_motion(self, event):
if not (event.Dragging() and event.LeftIsDown()):
return
ratio = self._to_ratio(event.GetPosition())
if ratio is None:
return
if self.crop_dragging:
x0, y0, _, _ = self.crop_rect
self.crop_rect = (x0, y0, ratio[0], ratio[1])
self.frame.on_crop_rect_changed(self._crop_rect_valid())
self.Refresh()
elif self.dragging:
self.frame.params["center"] = ratio
self.update_preview()
这里有一个桌面 GUI 的必备 API------CaptureMouse():鼠标按住后即使移出控件区域,控件依然能持续收到移动事件,否则用户一甩手拖出预览区,拖拽就"卡死"在半路。抬起时必须配对释放:
python
def on_left_up(self, event):
if self.crop_dragging:
self.crop_dragging = False
if self.HasCapture():
self.ReleaseMouse()
elif self.dragging:
self.dragging = False
if self.HasCapture():
self.ReleaseMouse()
HasCapture() 是防御性判断------某些平台上重复释放会报错。
3.11 90° 旋转:用转置变换,而不是重采样
把图片旋转 90°,不要用任意角度的 rotate()(那会引入重采样和新画布),Pillow 提供了专门的**转置(transpose)**操作,只做像素位置映射:
python
def on_rotate(self, clockwise):
if self.orig_image is None:
...
return
method = (Image.Transpose.ROTATE_270 if clockwise
else Image.Transpose.ROTATE_90)
self.orig_image = self.orig_image.transpose(method)
# 旋转后宽高互换:未确认的裁切框作废,水印回到中心
self.preview.clear_crop_rect()
self.params["center"] = (0.5, 0.5)
self.preview.update_preview()
可以连续点击,横图、竖图任意切换。这种旋转不经过插值,画质无损。
3.12 交互式裁切:归一化选框映射回原图
裁切流程是一个小型状态机:
- 点"剪切图片"(
ToggleButton)进入裁切模式:光标变十字、旋转按钮禁用(避免几何关系错乱); - 拖出选框,只有面积足够大时"确认剪切"才亮起;
- 确认 → 作用在原图上;取消 → 什么都不发生。
进入 / 退出模式时集中管理按钮状态:
python
def _enter_crop_mode(self):
self.crop_mode = True
self.preview.crop_rect = None
self.preview.SetCursor(wx.Cursor(wx.CURSOR_CROSS))
self.btn_crop_ok.Enable(False)
self.btn_crop_cancel.Enable(True)
self.btn_rotate_l.Enable(False)
self.btn_rotate_r.Enable(False)
def _exit_crop_mode(self):
self.crop_mode = False
self.btn_crop.SetValue(False)
self.preview.crop_rect = None
self.preview.SetCursor(wx.Cursor(wx.CURSOR_HAND))
self.btn_crop_ok.Enable(False)
self.btn_crop_cancel.Enable(False)
self.btn_rotate_l.Enable(True)
self.btn_rotate_r.Enable(True)
最关键的一步:预览选框是在缩略图 上画的,裁切却必须落在原始分辨率的图上。好在选框存的是归一化坐标,分别乘以原图的宽高即可精确还原:
python
def get_crop_box_orig(self):
"""把选框换算成原始工作图上的像素坐标 (left, top, right, bottom)。"""
if not self._crop_rect_valid():
return None
x0, y0, x1, y1 = self.crop_rect
width, height = self.frame.orig_image.size
left = max(0, int(min(x0, x1) * width))
right = min(width, int(max(x0, x1) * width))
top = max(0, int(min(y0, y1) * height))
bottom = min(height, int(max(y0, y1) * height))
if right - left < 2 or bottom - top < 2:
return None
return left, top, right, bottom
确认裁切:
python
def on_crop_confirm(self, event):
box = self.preview.get_crop_box_orig()
if box is None:
...
return
self.orig_image = self.orig_image.crop(box)
self._exit_crop_mode()
self.params["center"] = (0.5, 0.5)
self.preview.update_preview()
注意是 self.orig_image = self.orig_image.crop(...)------生成一张新图替换引用,原文件从头到尾没有被触碰。
3.13 EXIF 方向:手机照片的隐形坑
用手机竖着拍的照片,像素本身可能是横向存储的,方向靠 EXIF 中的 Orientation 标签描述。不处理它,程序里看到的图可能是"躺倒"的。Pillow 一行解决:
python
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
它会读取方向标签、按标签把像素旋转到正确朝向,并清掉该标签,保证"看到的像素"和"文件方向"一致。
3.14 导出:PNG 与 JPEG 的差异处理
导出时需要考虑两种格式的特性差异:PNG 支持透明通道,JPEG 不支持;默认文件名基于原图名加后缀;扩展名要与用户选择的过滤器匹配:
python
def on_export(self, event):
...
default_file = base_name + "_watermark.png"
dlg = wx.FileDialog(
self, defaultDir=base_dir, defaultFile=default_file,
wildcard="PNG 图片 (*.png)|*.png|JPEG 图片 (*.jpg)|*.jpg",
style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
)
...
is_jpeg = dlg.GetFilterIndex() == 1
if is_jpeg and not out_path.lower().endswith((".jpg", ".jpeg")):
out_path += ".jpg"
elif not is_jpeg and not out_path.lower().endswith(".png"):
out_path += ".png"
result = apply_watermark(self.orig_image, text=self.label_text(), ...)
if is_jpeg:
result.convert("RGB").save(out_path, quality=95) # 必须丢弃 alpha
else:
result.save(out_path)
对一张 RGBA 图像直接
save("x.jpg")会抛异常,这是 JPEG 格式规范决定的,必须先转 RGB。
标签文字本身由模板拼接生成,输入为空时使用占位符,避免生成半句:
python
LABEL_TEMPLATE = "本图片仅供%s使用,其他用途无效。%s"
def label_text(self):
purpose = self.txt_purpose.GetValue().strip() or "【用途】"
date = self.txt_date.GetValue().strip() or "【日期】"
return LABEL_TEMPLATE % (purpose, date)
四、踩坑记录(都是真实遇到的)
坑 1:wxPython 常量在不同构建中"消失"
程序曾在绘制裁切框时大量抛出:
AttributeError: module 'wx' has no attribute 'PENSTYLE_DASH'
原因是 wxPython 的不同构建中,笔样式常量的命名并不完全一致------有些版本里虚线叫 PENSTYLE_SHORT_DASH,没有 PENSTYLE_DASH。解决方案是在模块入口统一做回退,业务代码只引用自己定义的名字:
python
PEN_STYLE_DASH = getattr(
wx, "PENSTYLE_DASH",
getattr(wx, "PENSTYLE_SHORT_DASH", wx.PENSTYLE_SOLID),
)
PEN_STYLE_DOT = getattr(wx, "PENSTYLE_DOT", wx.PENSTYLE_SOLID)
坑 2:初始化顺序引发的"幽灵事件"
程序启动时曾报过一次 AttributeError: 'MainFrame' object has no attribute 'sld_font'。原因很反直觉:在界面构建过程中给文本框 SetValue(),会立刻同步触发 EVT_TEXT,而事件处理器里引用的滑块此时还没被创建出来。
python
# 错误:控件还没全部创建就赋值,事件处理器当场运行
self.txt_date = wx.TextCtrl(...)
self.txt_date.SetValue(today_str) # 💥 处理器引用了尚未创建的 sld_font
# 正确:把"初始数据填充"移到所有控件创建完毕之后
self.SetSizer(root)
self.txt_date.SetValue(today_str) # ✅
GUI 框架里的事件很多是同步派发的,"构建期不要触发完整业务逻辑"是一条通用经验。
坑 3:无头测试没有真正"绘制"
自动化冒烟测试里窗口只创建 1 秒就关闭,上面的 PENSTYLE_DASH 问题在测试中从未暴露------因为窗口可能没有真正执行到绘制路径。后来改用 wx.MemoryDC 直接调用绘制方法,强制覆盖渲染路径:
python
bmp = wx.Bitmap(w, h)
dc = wx.MemoryDC(bmp)
panel.crop_rect = (0.1, 0.2, 0.8, 0.7)
panel._draw_crop_rect(dc) # 确保画笔创建、选框绘制都真实执行
教训:"窗口创建成功"不等于"绘制代码正确",测试要直达目标代码路径。
坑 4:控件加错 Sizer
一次重构把行变量改名后,部分控件仍然 Add 到了旧的 sizer 上------程序不报错、功能正常,只是控件位置错乱。这类问题没有任何异常提示,只能靠肉眼或布局断言发现。重构 GUI 代码时,"创建"和"添加"最好放在同一个小函数里完成,减少跨段落引用变量名的机会。
五、可测试性:没有显示器也能测
得益于"合成层零 GUI 依赖"的设计,核心算法可以直接断言:
python
from PIL import Image
import watermark_app as m
img = Image.new("RGBA", (1000, 630), (210, 220, 235))
# 自动换行后的标签不应超过限定宽度
label = m.make_label_image(text, font_px=50, color=(220, 0, 0),
opacity=0.35, max_width=int(1000 * 0.78))
assert label.width <= int(1000 * 0.78) + 80
# 90° 旋转后宽高互换
rotated = img.transpose(Image.Transpose.ROTATE_270)
assert rotated.size == (630, 1000)
# 裁切框映射:归一化 (0.1~0.9) → 原图精确像素
# ... 直接调用 get_crop_box_orig 的换算逻辑断言
GUI 层则可以用 wx.CallLater 做定时自动关闭的冒烟测试,在 CI 中验证"窗口能正常构建、加载图片不崩溃"。
六、可以继续扩展的方向
- 撤销 / 重做:把每次旋转、裁切压入命令栈,支持多步回退;
- 图片 Logo 水印:合成层目前接受文字标签,扩展为接受任意 RGBA 图即可;
- 裁切约束:固定宽高比、吸附参考线;
- 批量处理:复用纯函数合成层,对一个文件夹批量套用水印;
- 预设保存:把参数序列化成 JSON,下次直接加载;
- 打包发布:用 PyInstaller 打成单 exe,免 Python 环境运行。
七、运行方式
bash
# 安装依赖
pip install wxPython Pillow
# 启动
python watermark_app.py
核心源码只有约 700 行,建议配合本文顺序阅读:先看纯函数(字体 → 换行 → 合成),再看预览面板,最后看主窗口的事件编排。
八、总结
这个小工具涉及的算法并不高深,真正的工程含量在三个地方:
- 坐标系统的设计------一切尺寸参数归一化,让预览与导出天然一致;
- 图层模型的抽象------文字先落透明 overlay,再一次性 alpha 合成,定位、旋转、平铺都获得了自由;
- 对框架细节的敬畏------同步事件的派发时机、双缓冲的配套要求、跨版本常量差异,每个坑都曾真实地让程序崩过一次。
希望这篇文章对你理解桌面端图像编程有帮助。如果动手实现,欢迎在评论区交流你遇到的坑。