博客黑白扫描书籍页面水印消除的极简方法中,介绍了一种暴力调整黑白图片对比度的方法来除去水印的方法,那个方法会抹去图片上颜色比水印更浅的细节。其实对图片细节影响更小的方法是先制作出除水印区域外其它部分全部是黑色(颜色值为(0,0,0),灰度值为0)的蒙版/遮罩(Mask),然后利用蒙版对原图片的像素进行过滤,将原图中对应蒙版中灰度值不为0的像素全部改为白色(这是对白底黑字的图片来说的,对于黑底白字的图片则蒙版的颜色要反过来,过滤的方法要改为原图中对应蒙版中灰度值为0的像素全部改为黑色)。
获取蒙版的方法可以用阈值调整:
mask = cv2.inRange(src, lowerb, upperb)
此函数将src中颜色值在阈值上下限之间的像素输出为白色,否则为黑色,输出结果为mask。
取得蒙版后消除原图中的水印则可以通过以下方式完成:
orig_image[mask > 0] = [255, 255, 255]
下面用Tkinter将黑白图片去水印制作成了一个GUI程序。从下面的预览对比可以看到蒙版模式保留了细节:

python
import tkinter as tk
from tkinter import ttk, filedialog
from PIL import Image, ImageTk
import cv2
class GrayWatermarkRemoverGUI:
def __init__(self):
self.root = tk.Tk()
self.root.title("黑白图片水印抹除工具")
# 初始化变量
self.original_image = None
self.current_image_path = None
self.lower_watermark = 80
self.upper_watermark = 180
# 线性模式参数
self.alpha_value = 2.2
self.beta_value = -50
# 模式变量
self.mode_var = tk.StringVar(value="mask")
screen_width = int(self.root.winfo_screenwidth() * 0.5)
screen_height = int(self.root.winfo_screenheight() * 0.7)
# 设置窗口大小为屏幕尺寸, 减去任务栏高度(假设为100像素)
self.root.geometry(f"{screen_width}x{screen_height - 100}+300+200")
self.setup_ui()
def setup_ui(self):
# 创建带滚动条的主画布
self.canvas = tk.Canvas(self.root, highlightthickness=0)
self.scrollbar_y = ttk.Scrollbar(self.root, orient="vertical", command=self.canvas.yview)
self.scrollbar_x = ttk.Scrollbar(self.root, orient="horizontal", command=self.canvas.xview)
self.canvas.configure(yscrollcommand=self.scrollbar_y.set, xscrollcommand=self.scrollbar_x.set)
self.scrollbar_y.pack(side="right", fill="y")
self.scrollbar_x.pack(side="bottom", fill="x")
self.canvas.pack(side="left", fill="both", expand=True)
# 创建主内容框架
self.main_frame = ttk.Frame(self.canvas)
self.canvas_window = self.canvas.create_window((0, 0), window=self.main_frame, anchor="nw")
# 绑定配置事件以更新滚动区域
self.main_frame.bind("<Configure>", self._on_frame_configure)
self.canvas.bind("<Configure>", self._on_canvas_configure)
# 绑定鼠标滚轮滚动
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
self.canvas.bind_all("<Shift-MouseWheel>", self._on_horizontal_mousewheel)
# ============ 第一行:缩略图显示区 ============
self.thumb_frame = ttk.Frame(self.main_frame)
self.thumb_frame.pack(fill="both", expand=True, padx=10, pady=10)
# 左侧原始图显示区
self.left_panel = ttk.LabelFrame(self.thumb_frame, text="原始图像", width=600, height=400)
self.left_panel.pack(side="left", fill="both", expand=True, padx=(0, 5))
self.left_panel.pack_propagate(False)
self.left_canvas = tk.Canvas(self.left_panel, bg="gray90")
self.left_canvas.pack(fill="both", expand=True, padx=5, pady=5)
# 右侧mask显示区
self.right_panel = ttk.LabelFrame(self.thumb_frame, text="Mask/Result", width=600, height=400)
self.right_panel.pack(side="right", fill="both", expand=True, padx=(5, 0))
self.right_panel.pack_propagate(False)
self.right_canvas = tk.Canvas(self.right_panel, bg="gray90")
self.right_canvas.pack(fill="both", expand=True, padx=5, pady=5)
# ============ 第二行:模式选择和阈值控件 ============
self.mode_frame = ttk.LabelFrame(self.main_frame, text="水印处理模式", padding=10)
self.mode_frame.pack(fill="x", padx=10, pady=10)
# 模式选择按钮
mode_btn_frame = ttk.Frame(self.mode_frame)
mode_btn_frame.pack(fill="x", pady=5)
self.mask_mode_btn = ttk.Radiobutton(mode_btn_frame, text="Mask模式(阈值分割)",
variable=self.mode_var, value="mask",
command=self.switch_to_mask_mode)
self.mask_mode_btn.pack(side="left", padx=20)
self.linear_mode_btn = ttk.Radiobutton(mode_btn_frame, text="暴力模式(对比度调整)",
variable=self.mode_var, value="linear",
command=self.switch_to_linear_mode)
self.linear_mode_btn.pack(side="left", padx=20)
# 遮罩模式滑块
self.mask_slider_frame = ttk.Frame(self.mode_frame)
self.mask_slider_frame.pack(fill="x", pady=5)
# 左边滑块 - lower_watermark
self.lower_frame = ttk.Frame(self.mask_slider_frame)
self.lower_frame.pack(side="left", fill="x", expand=True, padx=(0, 20))
ttk.Label(self.lower_frame, text="下限值:").pack(anchor="w")
self.lower_value_label = ttk.Label(self.lower_frame, text=str(self.lower_watermark), foreground="blue")
self.lower_value_label.pack(anchor="w")
self.lower_slider = ttk.Scale(
self.lower_frame,
from_=0,
to=255,
orient="horizontal",
command=self.on_lower_change,
value=self.lower_watermark
)
self.lower_slider.pack(fill="x", pady=5)
# 右边滑块 - upper_watermark
self.upper_frame = ttk.Frame(self.mask_slider_frame)
self.upper_frame.pack(side="right", fill="x", expand=True, padx=(20, 0))
ttk.Label(self.upper_frame, text="上限值:").pack(anchor="w")
self.upper_value_label = ttk.Label(self.upper_frame, text=str(self.upper_watermark), foreground="red")
self.upper_value_label.pack(anchor="w")
self.upper_slider = ttk.Scale(
self.upper_frame,
from_=0,
to=255,
orient="horizontal",
command=self.on_upper_change,
value=self.upper_watermark
)
self.upper_slider.pack(fill="x", pady=5)
# 线性模式滑块(初始隐藏)
self.linear_slider_frame = ttk.Frame(self.mode_frame)
self.linear_slider_frame.pack(fill="x", pady=5)
# Alpha滑块(左侧)
self.alpha_frame = ttk.Frame(self.linear_slider_frame)
self.alpha_frame.pack(side="left", fill="x", expand=True, padx=(0, 20))
ttk.Label(self.alpha_frame, text="Alpha (对比度):").pack(anchor="w")
self.alpha_value_label = ttk.Label(self.alpha_frame, text=f"{self.alpha_value:.1f}", foreground="blue")
self.alpha_value_label.pack(anchor="w")
self.alpha_slider = ttk.Scale(
self.alpha_frame,
from_=0.1,
to=5.0,
orient="horizontal",
command=self.on_alpha_change,
value=self.alpha_value
)
self.alpha_slider.pack(fill="x", pady=5)
# Beta滑块(右侧)
self.beta_frame = ttk.Frame(self.linear_slider_frame)
self.beta_frame.pack(side="right", fill="x", expand=True, padx=(20, 0))
ttk.Label(self.beta_frame, text="Beta (亮度):").pack(anchor="w")
self.beta_value_label = ttk.Label(self.beta_frame, text=str(self.beta_value), foreground="red")
self.beta_value_label.pack(anchor="w")
self.beta_slider = ttk.Scale(
self.beta_frame,
from_=-150,
to=150,
orient="horizontal",
command=self.on_beta_change,
value=self.beta_value
)
self.beta_slider.pack(fill="x", pady=5)
# 初始化显示状态
self.linear_slider_frame.pack_forget()
# ============ 第三行:RGB转灰度计算器 ============
self.calc_frame = ttk.LabelFrame(self.main_frame, text="RGB转灰度计算器", padding=10)
self.calc_frame.pack(fill="x", padx=10, pady=10)
# RGB输入框
input_frame = ttk.Frame(self.calc_frame)
input_frame.pack(fill="x", pady=5)
ttk.Label(input_frame, text="R:").pack(side="left", padx=5)
self.r_input_var = tk.StringVar(value="128")
self.r_input = ttk.Entry(input_frame, textvariable=self.r_input_var, width=8)
self.r_input.pack(side="left", padx=5)
ttk.Label(input_frame, text="G:").pack(side="left", padx=5)
self.g_input_var = tk.StringVar(value="128")
self.g_input = ttk.Entry(input_frame, textvariable=self.g_input_var, width=8)
self.g_input.pack(side="left", padx=5)
ttk.Label(input_frame, text="B:").pack(side="left", padx=5)
self.b_input_var = tk.StringVar(value="128")
self.b_input = ttk.Entry(input_frame, textvariable=self.b_input_var, width=8)
self.b_input.pack(side="left", padx=5)
# 计算按钮
self.calc_btn = ttk.Button(input_frame, text="计算灰度值", command=self.calculate_gray)
self.calc_btn.pack(side="left", padx=20)
# 结果显示
self.result_frame = ttk.Frame(input_frame)
self.result_frame.pack(fill="x", pady=5)
# 颜色预览
self.color_preview = tk.Canvas(self.result_frame, width=40, height=40, bg="#808080")
self.color_preview.pack(side="right", padx=20)
self.gray_result_label = ttk.Label(self.result_frame, text="128", foreground="blue", font=('Arial', 14, 'bold'))
self.gray_result_label.pack(side="right", padx=5)
ttk.Label(self.result_frame, text="灰度值:", font=('Arial', 12, 'bold')).pack(side="right", padx=5)
# ============ 第四行:文件操作按钮(固定在底部) ============
self.btn_frame = ttk.Frame(self.main_frame)
self.btn_frame.pack(fill="x", padx=10, pady=10)
self.open_btn = ttk.Button(self.btn_frame, text="📂 打开文件", command=self.open_file)
self.open_btn.pack(side="left", padx=20)
self.preview_btn = ttk.Button(self.btn_frame, text="👁️ 预览结果", command=self.preview_result, state="disabled")
self.preview_btn.pack(side="left", padx=20)
self.save_btn = ttk.Button(self.btn_frame, text="💾 保存文件", command=self.save_file, state="disabled")
self.save_btn.pack(side="right", padx=20)
def _on_frame_configure(self, event=None):
"""更新滚动区域"""
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def _on_canvas_configure(self, event=None):
"""当画布尺寸变化时,重置内部窗口宽度以匹配画布并保持居中"""
canvas_width = event.width
# 获取当前内容所需的宽度
content_width = self.main_frame.winfo_reqwidth()
# 如果画布宽度大于内容宽度,则调整内部窗口宽度使内容居中
if canvas_width > content_width:
self.canvas.itemconfig(self.canvas_window, width=canvas_width)
# 通过调整 anchor 和坐标实现居中
self.canvas.coords(self.canvas_window, (canvas_width - content_width) // 2, 0)
else:
self.canvas.itemconfig(self.canvas_window, width=content_width)
self.canvas.coords(self.canvas_window, 0, 0)
def _on_mousewheel(self, event):
"""垂直滚动"""
# 获取当前内容所需的宽度
content_height = self.main_frame.winfo_reqheight()
if content_height > self.canvas.winfo_height():
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def _on_horizontal_mousewheel(self, event):
"""水平滚动"""
self.canvas.xview_scroll(int(-1 * (event.delta / 120)), "units")
def switch_to_mask_mode(self):
"""切换到遮罩模式"""
self.mask_slider_frame.pack(fill="x", pady=5)
self.linear_slider_frame.pack_forget()
if self.original_image is not None:
self.update_mask()
def switch_to_linear_mode(self):
"""切换到线性模式"""
self.mask_slider_frame.pack_forget()
self.linear_slider_frame.pack(fill="x", pady=5)
if self.original_image is not None:
self.update_mask()
def calculate_gray(self):
"""计算RGB转灰度值"""
try:
r = int(self.r_input_var.get())
g = int(self.g_input_var.get())
b = int(self.b_input_var.get())
# 验证输入范围
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
raise ValueError("RGB值必须在0-255范围内")
# OpenCV灰度转换公式: Gray = 0.299*R + 0.587*G + 0.114*B
gray_value = int(0.299 * r + 0.587 * g + 0.114 * b)
# 更新显示
self.gray_result_label.config(text=str(gray_value))
# 更新颜色预览
color_hex = f"#{r:02x}{g:02x}{b:02x}"
gray_hex = f"#{gray_value:02x}{gray_value:02x}{gray_value:02x}"
self.color_preview.config(bg=color_hex)
# 更新灰度预览
self.color_preview.config(bg=gray_hex)
except ValueError as e:
self.gray_result_label.config(text=f"错误: {str(e)}")
return
def on_alpha_change(self, value):
"""Alpha滑块改变"""
self.alpha_value = float(value)
self.alpha_value_label.config(text=f"{self.alpha_value:.1f}")
self.update_mask()
def on_beta_change(self, value):
"""Beta滑块改变"""
self.beta_value = int(float(value))
self.beta_value_label.config(text=str(self.beta_value))
self.update_mask()
def open_file(self):
file_path = filedialog.askopenfilename(
title="选择图像文件",
filetypes=[
("图像文件", "*.png *.jpg *.jpeg *.bmp"),
("所有文件", "*.*")
]
)
if file_path:
self.current_image_path = file_path
self.load_image(file_path)
def load_image(self, image_path):
"""加载图像并显示"""
self.original_image = cv2.imread(image_path)
if self.original_image is None:
return
# 显示原始图像
self.display_original()
# 更新mask
self.update_mask()
# 启用保存和预览按钮
self.save_btn.config(state="normal")
self.preview_btn.config(state="normal")
def display_original(self):
"""在左侧画布显示原始图像"""
if self.original_image is None:
return
# 转换为PIL图像
img_rgb = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2RGB)
img_pil = Image.fromarray(img_rgb)
# 计算缩放比例
canvas_width = self.left_canvas.winfo_width() - 10
canvas_height = self.left_canvas.winfo_height() - 10
if canvas_width <= 1 or canvas_height <= 1:
canvas_width = 580
canvas_height = 380
scale = min(canvas_width / img_pil.width, canvas_height / img_pil.height, 1.0)
new_width = int(img_pil.width * scale)
new_height = int(img_pil.height * scale)
img_resized = img_pil.resize((new_width, new_height), Image.Resampling.LANCZOS)
# 转换为PhotoImage
self.left_photo = ImageTk.PhotoImage(img_resized)
# 居中显示
self.left_canvas.delete("all")
x_offset = (canvas_width - new_width) // 2
y_offset = (canvas_height - new_height) // 2
self.left_canvas.create_image(x_offset, y_offset, anchor="nw", image=self.left_photo)
def update_mask(self):
"""根据当前模式更新显示"""
if self.original_image is None:
return
if self.mode_var.get() == "mask":
# 遮罩模式:转换为灰度图
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
# 使用cv2.inRange生成mask
watermark_mask = cv2.inRange(gray, self.lower_watermark, self.upper_watermark)
else:
# 线性模式:显示cv2.convertScaleAbs的处理结果
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
# 使用cv2.convertScaleAbs进行线性变换
watermark_mask = cv2.convertScaleAbs(gray, alpha=self.alpha_value, beta=self.beta_value)
# 将结果转换为PIL图像显示
mask_pil = Image.fromarray(watermark_mask)
# 计算缩放比例
canvas_width = self.right_canvas.winfo_width() - 10
canvas_height = self.right_canvas.winfo_height() - 10
if canvas_width <= 1 or canvas_height <= 1:
canvas_width = 580
canvas_height = 380
scale = min(canvas_width / mask_pil.width, canvas_height / mask_pil.height, 1.0)
new_width = int(mask_pil.width * scale)
new_height = int(mask_pil.height * scale)
mask_resized = mask_pil.resize((new_width, new_height), Image.Resampling.NEAREST)
# 转换为PhotoImage
self.right_photo = ImageTk.PhotoImage(mask_resized)
# 居中显示
self.right_canvas.delete("all")
x_offset = (canvas_width - new_width) // 2
y_offset = (canvas_height - new_height) // 2
self.right_canvas.create_image(x_offset, y_offset, anchor="nw", image=self.right_photo)
def on_lower_change(self, value):
"""下限滑块改变"""
self.lower_watermark = int(float(value))
self.lower_value_label.config(text=str(self.lower_watermark))
self.update_mask()
def on_upper_change(self, value):
"""上限滑块改变"""
self.upper_watermark = int(float(value))
self.upper_value_label.config(text=str(self.upper_watermark))
self.update_mask()
def preview_result(self):
"""预览去水印后的结果"""
if self.original_image is None:
return
# 根据模式生成去水印结果
if self.mode_var.get() == "mask":
# 遮罩模式
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
watermark_mask = cv2.inRange(gray, self.lower_watermark, self.upper_watermark)
result = self.original_image.copy()
result[watermark_mask > 0] = [255, 255, 255]
else:
# 线性模式:先对灰度图进行线性变换,再转回BGR
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
adjusted = cv2.convertScaleAbs(gray, alpha=self.alpha_value, beta=self.beta_value)
# 将调整后的灰度图转为3通道
result = cv2.cvtColor(adjusted, cv2.COLOR_GRAY2BGR)
# 创建预览窗口
preview_window = tk.Toplevel(self.root)
preview_window.title(f"去水印结果预览------{'蒙版模式' if self.mode_var.get() == 'mask' else '暴力模式'}")
preview_window.geometry("800x600")
# 创建画布
canvas = tk.Canvas(preview_window, bg="gray90")
canvas.pack(fill="both", expand=True, padx=10, pady=10)
# 转换为PIL图像
result_rgb = cv2.cvtColor(result, cv2.COLOR_BGR2RGB)
result_pil = Image.fromarray(result_rgb)
# 获取窗口尺寸
preview_window.update()
canvas_width = canvas.winfo_width() - 20
canvas_height = canvas.winfo_height() - 20
# 计算缩放比例
scale = min(canvas_width / result_pil.width, canvas_height / result_pil.height, 1.0)
new_width = int(result_pil.width * scale)
new_height = int(result_pil.height * scale)
result_resized = result_pil.resize((new_width, new_height), Image.Resampling.LANCZOS)
# 转换为PhotoImage
preview_photo = ImageTk.PhotoImage(result_resized)
# 居中显示
x_offset = (canvas_width - new_width) // 2
y_offset = (canvas_height - new_height) // 2
canvas.create_image(x_offset, y_offset, anchor="nw", image=preview_photo)
# 保存引用防止被垃圾回收
canvas.preview_photo = preview_photo
# 添加关闭按钮
close_btn = ttk.Button(preview_window, text="关闭", command=preview_window.destroy)
close_btn.pack(pady=10)
def save_file(self):
"""保存去水印后的图像"""
if self.original_image is None:
return
# 根据模式生成去水印结果
if self.mode_var.get() == "mask":
# 遮罩模式
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
watermark_mask = cv2.inRange(gray, self.lower_watermark, self.upper_watermark)
result = self.original_image.copy()
result[watermark_mask > 0] = [255, 255, 255]
else:
# 线性模式:先对灰度图进行线性变换,再转回BGR
gray = cv2.cvtColor(self.original_image, cv2.COLOR_BGR2GRAY)
adjusted = cv2.convertScaleAbs(gray, alpha=self.alpha_value, beta=self.beta_value)
# 将调整后的灰度图转为3通道
result = cv2.cvtColor(adjusted, cv2.COLOR_GRAY2BGR)
# 选择保存路径
save_path = filedialog.asksaveasfilename(
title="保存去水印图像",
defaultextension=".png",
filetypes=[
("PNG文件", "*.png"),
("JPEG文件", "*.jpg"),
("所有文件", "*.*")
]
)
if save_path:
cv2.imwrite(save_path, result)
print(f"已保存: {save_path}")
def run(self):
self.root.mainloop()
if __name__ == "__main__":
app = GrayWatermarkRemoverGUI()
app.run()
程序以ttk作为GUI控件库,比原始的tkinter似乎美观一点,运行界面如下(可以选择蒙版模式和暴力模式。经试验,蒙版模式适应范围会更小一点,有些蒙版模式无法去掉的水印暴力模式能够去掉,只是会丢失细节。此程序稍作扩充和修改,就可以批量处理图片,也就能一次性处理一个pdf文件):
