跟水印杠上了——顺便巩固Tkinter的GUI编程

博客黑白扫描书籍页面水印消除的极简方法中,介绍了一种暴力调整黑白图片对比度的方法来除去水印的方法,那个方法会抹去图片上颜色比水印更浅的细节。其实对图片细节影响更小的方法是先制作出除水印区域外其它部分全部是黑色(颜色值为(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文件):

相关推荐
2301_803934611 小时前
html标签怎样划分页面区域_section与div的区别【介绍】
jvm·数据库·python
知学致远1 小时前
Python基础语法_01-注释、输入输出、变量
python
沈浩(种子思维作者)1 小时前
物理的本质是数学,还是数学只是描述物理的方便之语?
人工智能·python·算法
Cloud_Shy6182 小时前
Python 数据分析基础入门:《Excel Python:飞速搞定数据分析与处理》学习笔记系列(第十章 Python 驱动的 Excel 工具 下篇)
笔记·python·学习·数据分析·excel·pandas
2401_824697662 小时前
如何管理Oracle服务器的内核共享内存_shmmax与shmall计算
jvm·数据库·python
2301_783848652 小时前
mysql数据迁移过程如何降低性能影响_采用增量备份与多线程同步
jvm·数据库·python
2401_884454152 小时前
CSS如何快速实现网站换肤功能_利用CSS变量重置全局颜色方案
jvm·数据库·python
存在morning2 小时前
【GO语言开发实践】一 GO 语法快速上手
开发语言·python·golang
晨曦中的暮雨2 小时前
Python 并发模型理解:GIL、线程、async 到底是什么关系
开发语言·python