来个去照片背景的GUI程序
女朋友是不是曾经发给你一张证件照,让你把照片的背景换个颜色?你是不是马上去找一个AI去背景的网站上传照片完成去背景?在这个AI可以换脸的时代,这么做对女朋友可是有点保护不周啊!怎么办?当然是自己手搓一个Python解决啦——当然也可以让AI帮你搓大部分代码,自己想想要些什么功能就可以了。
下面的程序是copilot(背后是ChatGPT-4o)、Kimi、MarsCode(背后是豆包)共同协作在玩票程序员的指挥下完成的作品,Cursor(背后是Claude)只让我用了一天就要我交钱,作为一个禄存星守财帛宫的人,我怎么可能让它如愿?只好让它缺席了。不过老实说在写代码方面Claude还是最强。为什么要用多个AI协作?因为不同的AI似乎写程序的思路居然有差别,而且一旦它的程序出错了,有一定的概率它不知道怎么改,换个AI却又能搞清怎么改。当然,下面的程序里开始出了个问题3个AI都没有解决。点击标记前景区按钮后,程序进入标记前景模式,这时候我想让其它按钮和色彩信息输入相关控件(基本上也就是除了图片显示控件之外的所有其它控件)失效,AI一开始给出了下面的代码:
def disable_controls(self):"""禁用除图片显示控件外的所有控件"""for widget in self.findChildren(QWidget):if widget != self.image_display: # 排除图片显示控件widget.setDisabled(True)
逻辑上似乎挺正确,除了图片显示控件其他的控件都失效。但实际上findChildren方法遍历的对象远超表面上看起来的那几个,这样做图片显示控件也会失去对鼠标事件的响应,即使加上
self.image_display.setFocusPolicy(Qt.StrongFocus)
强制让它成为焦点响应鼠标事件也不行。
除了这个,程序的难点就是图片去背景的运算,但这对numpy来说其实是小事,不过是一个矩阵运算而已。下面的代码就是这个程序的核心技术:
# 确保图像为 RGBA 模式
img = self.orig_img_data.convert("RGBA")
img_array = np.array(img) # 转换为 NumPy 数组
r, g, b, a = img_array[..., 0], img_array[..., 1], img_array[..., 2], img_array[..., 3]# 创建前景掩码
front_mask = np.zeros((img_array.shape[0], img_array.shape[1]), dtype=bool)
for rect in self.image_display.front_areas:x1, y1, x2, y2 = rect.getCoords()front_mask[y1:y2, x1:x2] = True# 清除背景逻辑(排除前景区域)
if self.rgb_mode.isChecked():# 获取 RGB 输入值r_val = int(self.rgb_r_edit.text()) if self.rgb_r_edit.text() else 0g_val = int(self.rgb_g_edit.text()) if self.rgb_g_edit.text() else 0b_val = int(self.rgb_b_edit.text()) if self.rgb_b_edit.text() else 0r_condition = r >= r_val if self.rgb_r_min.isChecked() else r < r_valg_condition = g >= g_val if self.rgb_g_min.isChecked() else g < g_valb_condition = b >= b_val if self.rgb_b_min.isChecked() else b < b_valmask = r_condition & g_condition & b_condition & ~front_maskimg_array[mask, 3] = 0 # 将 Alpha 通道设置为 0(透明)elif self.gray_mode.isChecked():gray_img = self.orig_img_data.convert("L")gray_array = np.array(gray_img)gray_val = int(self.gray_edit.text()) if self.gray_edit.text() else 0gray_condition = gray_array >= gray_val if self.gray_min_checkbox.isChecked() else gray_array < gray_valmask = gray_condition & ~front_maskimg_array[mask, 3] = 0 # 将 Alpha 通道设置为 0(透明)
基本思路是创建一张与原始图片相同尺寸的掩码图片,初始化数据都是0,然后将标记为前景的区域设置为1,按位求反(当然也可以用ones初始化为1,将标记为前景的区域设置为0,省掉求反的步骤,不过运算速度可能反而更慢),再与根据去背景的条件进行与操作,要保留的像素所在位置就都置0(False)了,然后用
img_array[mask, 3] = 0
将掩码 mask
中值为 True
的像素位置的 Alpha 通道值设置为 0,从而使这些像素变为透明。
下面是完整代码,有一些行可能是不必要的,例如mark_front方法中的:
self.image_display.setDisabled(False)
self.image_display.setFocusPolicy(Qt.StrongFocus)
等等,为了防止意外发生,这些有助于提高程序健壮性的代码就都保留了。
import sys
from tkinter import SEL
import numpy as np
from PIL import Image
from PyQt5.QtWidgets import (QApplication,QMainWindow,QHBoxLayout,QVBoxLayout,QPushButton,QLabel,QLineEdit,QRadioButton,QFileDialog,QMessageBox,QGridLayout,QFrame, QCheckBox,QGroupBox,QWidget
)
from PyQt5.QtGui import QPixmap, QPainter, QColor, QBrush, QPen, QImage
from PyQt5.QtCore import Qt, QRect, pyqtSignal
from PyQt5.QtGui import QIntValidatorclass ImageDisplay(QFrame):screenshot_finished = pyqtSignal() # 定义前景标记完成信号def __init__(self, parent=None):super().__init__(parent)self.setFixedSize(800, 800)self.setFrameShape(QFrame.Box)self.setFrameShadow(QFrame.Sunken)self.image = Noneself.checkerboard_background = self.create_checkerboard_background()self.drawing = Falseself.start_point = Noneself.end_point = Noneself.front_areas = [] # 记录所有前景区域(未映射的原始坐标)self.saved_rects = [] # 保存所有绘制的虚线框(未映射的原始坐标)self.image_offset = (0, 0) # 图片在控件中的偏移量self.image_scaled = False # 图片是否被缩放def set_image(self, image_path):self.image = QPixmap(image_path)self.update()def enter_screenshot_mode(self):"""进入前景标记模式"""self.drawing = Trueself.start_point = Noneself.end_point = Noneself.saved_rects = [] # 清空所有已绘制的虚线框self.setCursor(Qt.CrossCursor) # 设置鼠标为十字光标def leave_screenshot_mode(self):"""退出前景标记模式"""self.drawing = Falseself.setCursor(Qt.ArrowCursor) # 恢复鼠标为箭头光标def mousePressEvent(self, event):if self.drawing and event.button() == Qt.LeftButton:self.start_point = event.pos()self.end_point = Noneself.update()elif self.drawing and event.button() == Qt.RightButton:# 右键退出前景标记模式self.leave_screenshot_mode()self.screenshot_finished.emit() # 发射前景标记完成信号def mouseMoveEvent(self, event):if self.drawing and self.start_point:self.end_point = event.pos()self.update()def mouseReleaseEvent(self, event):if self.drawing and event.button() == Qt.LeftButton:self.end_point = event.pos()rect = QRect(self.start_point, self.end_point).normalized()self.saved_rects.append(rect) # 保存未映射的原始坐标self.map_rect_to_image_coordinates(rect) # 映射到图片像素坐标self.update()def paintEvent(self, event):super().paintEvent(event)painter = QPainter(self)# 绘制背景方格if self.checkerboard_background:painter.drawPixmap(0, 0, self.checkerboard_background)# 绘制图像if self.image:painter.setRenderHint(painter.Antialiasing)# 判断是否需要缩放图片if self.image.width() > self.width() or self.image.height() > self.height():scaled_image = self.image.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)self.image_scaled = Truex = (self.width() - scaled_image.width()) // 2y = (self.height() - scaled_image.height()) // 2self.image_offset = (x, y)painter.drawPixmap(x, y, scaled_image)else:self.image_scaled = Falsex = (self.width() - self.image.width()) // 2y = (self.height() - self.image.height()) // 2self.image_offset = (x, y)painter.drawPixmap(x, y, self.image)# 绘制所有已保存的虚线框(未映射的原始坐标)pen = QPen(QColor(255, 0, 0), 2, Qt.DashLine) # 红色虚线painter.setPen(pen)for rect in self.saved_rects:painter.drawRect(rect)# 绘制当前正在框选前景区的矩形if self.drawing and self.start_point and self.end_point:rect = QRect(self.start_point, self.end_point).normalized()painter.drawRect(rect)def map_rect_to_image_coordinates(self, rect):"""将矩形区域映射到加载的图片的像素坐标上"""if self.image and rect:x_offset, y_offset = self.image_offset# 计算映射后的坐标x1 = max(0, rect.left() - x_offset)y1 = max(0, rect.top() - y_offset)x2 = min(self.width(), rect.right() - x_offset)y2 = min(self.height(), rect.bottom() - y_offset)if self.image_scaled:# 按比例映射到原始图像尺寸scaled_image = self.image.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)scale_x = self.image.width() / scaled_image.width()scale_y = self.image.height() / scaled_image.height()x1 = int(x1 * scale_x)y1 = int(y1 * scale_y)x2 = int(x2 * scale_x)y2 = int(y2 * scale_y)else:# 图片未缩放,直接使用原始坐标x1 = int(x1)y1 = int(y1)x2 = int(x2)y2 = int(y2)# 保存映射后的矩形self.front_areas.append(QRect(x1, y1, x2 - x1, y2 - y1))def create_checkerboard_background(self):"""创建5x5的白色和灰色方格相间的背景图片"""size = 20 # 每个方格的大小(像素)rows, cols = 40, 40 # 方格的行数和列数pixmap = QPixmap(size * cols, size * rows)pixmap.fill(Qt.transparent) # 设置背景透明painter = QPainter(pixmap)for row in range(rows):for col in range(cols):# 计算方格颜色if (row + col) % 2 == 0:color = QColor(255, 255, 255, 255) # 白色else:color = QColor(218, 218, 218, 215) # 灰色brush = QBrush(color)painter.fillRect(col * size, row * size, size, size, brush)painter.end()return pixmapcomment = ('1、本程序适用于背景较为单一且前景与背景对比明显的图片去背景,例如证件照去背景。\n\n'
'2、点击标记前景按钮进入前景标记模式,可在图片上按下鼠标左键框选前景区域加以保护,'
'防止前景因颜色与背景相近被误删。每次进入前景标记模式都会清除此前已标记的前景区域。\n\n'
'3、前景标记模式下鼠标右键点击图片可退出前景标记模式,进行其他操作操作。\n\n'
'4、去背景的效果取决于指定的颜色信息,可以通过GIMP,Photoshop等图片处理软件查看图片背景的RGB颜色分量值或灰度值。')class MainWindow(QMainWindow):def __init__(self):super().__init__()self.orig_img_data = None # 原始图片数据self.nobg_img_data = None # 去背景后的图片数据self.setWindowTitle("Image Background Processor")self.setGeometry(100, 100, 1200, 800)# 主布局main_layout = QHBoxLayout()# 第一列:图片显示控件self.image_display = ImageDisplay()main_layout.addWidget(self.image_display)# 第二列:纵向布局right_column_layout = QVBoxLayout()right_column_layout.setSpacing(10) # 设置纵向间距# 1. 添加说明标签instruction_label = QLabel("说明")instruction_label.setWordWrap(True)instruction_label.setStyleSheet("font-size:4em;text-align:center;font-weight:bold;color:red")instruction_label.setAlignment(Qt.AlignCenter)instruction_label.setContentsMargins(0, 10, 0, 0) # 添加一些上部间距right_column_layout.addWidget(instruction_label)instruction_label = QLabel(comment)instruction_label.setStyleSheet("font-size:2em;line-height:1.2em;font-family:'微软雅黑';")instruction_label.setWordWrap(True)instruction_label.setContentsMargins(0, 0, 0, 10) # 添加一些底部间距right_column_layout.addWidget(instruction_label)# 2. 载入图片按钮load_button_group = QGroupBox()load_button_layout = QHBoxLayout()load_button_layout.setSpacing(10) # 设置按钮间距self.load_button = QPushButton("载入图片")self.load_button.clicked.connect(self.load_image)self.load_button.setFixedWidth(120) # 设置按钮宽度mark_button = QPushButton("标记前景")mark_button.clicked.connect(self.mark_front)mark_button.setFixedWidth(120) # 设置按钮宽度load_button_layout.addWidget(self.load_button)load_button_layout.addWidget(mark_button)load_button_group.setLayout(load_button_layout)right_column_layout.addWidget(load_button_group)# 3. 图片背景颜色控件组color_group = QGroupBox("图片背景颜色")color_layout = QVBoxLayout()color_layout.setSpacing(30) # 设置组内纵向间距# RGB模式控件组rgb_group = QGroupBox("RGB模式")rgb_layout = QGridLayout()self.rgb_mode = QRadioButton("RGB模式")self.rgb_mode.clicked.connect(self.mode_changed)rgb_layout.addWidget(self.rgb_mode, 0, 0, 1, 2)# RGB分量输入框self.rgb_r_label = QLabel("R(红色)分量:")self.rgb_r_edit = QLineEdit()self.rgb_r_edit.setPlaceholderText("0~255")self.rgb_r_edit.setValidator(QIntValidator(0, 255))self.rgb_r_edit.textChanged.connect(self.validate_input)self.rgb_r_min = QCheckBox("最小值")self.rgb_r_min.setToolTip("背景中红色较深时选择")rgb_layout.addWidget(self.rgb_r_label, 1, 0)rgb_layout.addWidget(self.rgb_r_edit, 1, 1)rgb_layout.addWidget(self.rgb_r_min, 1, 2)self.rgb_g_label = QLabel("G(绿色)分量:")self.rgb_g_edit = QLineEdit()self.rgb_g_edit.setPlaceholderText("0~255")self.rgb_g_edit.setValidator(QIntValidator(0, 255))self.rgb_g_edit.textChanged.connect(self.validate_input)self.rgb_g_min = QCheckBox("最小值")self.rgb_g_min.setToolTip("背景中绿色较深时选择")rgb_layout.addWidget(self.rgb_g_label, 2, 0)rgb_layout.addWidget(self.rgb_g_edit, 2, 1)rgb_layout.addWidget(self.rgb_g_min, 2, 2)self.rgb_b_label = QLabel("B(蓝色)分量:")self.rgb_b_edit = QLineEdit()self.rgb_b_edit.setPlaceholderText("0~255")self.rgb_b_edit.setValidator(QIntValidator(0, 255))self.rgb_b_edit.textChanged.connect(self.validate_input)self.rgb_b_min = QCheckBox("最小值")self.rgb_b_min.setToolTip("背景中蓝色较深时选择")rgb_layout.addWidget(self.rgb_b_label, 3, 0)rgb_layout.addWidget(self.rgb_b_edit, 3, 1)rgb_layout.addWidget(self.rgb_b_min, 3, 2)rgb_group.setLayout(rgb_layout)color_layout.addWidget(rgb_group)# 灰度模式控件组gray_group = QGroupBox("灰度模式")gray_layout = QGridLayout()self.gray_mode = QRadioButton("灰度模式")self.gray_mode.clicked.connect(self.mode_changed)gray_layout.addWidget(self.gray_mode, 0, 0)self.gray_label = QLabel("灰度:")self.gray_edit = QLineEdit()self.gray_edit.setPlaceholderText("0~255")self.gray_edit.setValidator(QIntValidator(0, 255))self.gray_edit.textChanged.connect(self.validate_input)self.gray_min_checkbox = QCheckBox("最小值")self.gray_min_checkbox.setToolTip("背景颜色灰度较浅偏白时选择")gray_layout.addWidget(self.gray_label, 1, 0)gray_layout.addWidget(self.gray_edit, 1, 1)gray_layout.addWidget(self.gray_min_checkbox, 1, 2)gray_group.setLayout(gray_layout)color_layout.addWidget(gray_group)color_group.setLayout(color_layout)right_column_layout.addWidget(color_group)self.gray_mode.click()# 4. 清除背景和保存结果按钮clear_save_group = QGroupBox()clear_save_layout = QHBoxLayout()self.clear_button = QPushButton("清除背景")self.clear_button.clicked.connect(self.clear_background)self.clear_button.setFixedWidth(120) # 设置按钮宽度clear_save_layout.addWidget(self.clear_button)self.orig_button = QPushButton("原始图像")self.orig_button.clicked.connect(self.show_orig_image)self.orig_button.setFixedWidth(120) # 设置按钮宽度clear_save_layout.addWidget(self.orig_button)self.save_button = QPushButton("保存结果")self.save_button.clicked.connect(self.save_result)self.save_button.setFixedWidth(120) # 设置按钮宽度clear_save_layout.addWidget(self.save_button)clear_save_group.setLayout(clear_save_layout)clear_save_group.setContentsMargins(0, 0, 0, 10)right_column_layout.addWidget(clear_save_group)# 将第二列布局添加到主布局main_layout.addLayout(right_column_layout)self.controls_to_disable = [self.load_button,self.rgb_mode, self.rgb_r_edit, self.rgb_r_min, self.rgb_g_edit, self.rgb_g_min, self.rgb_b_edit, self.rgb_b_min, self.gray_mode, self.gray_edit, self.gray_min_checkbox,self.load_button, self.clear_button,self.save_button, self.orig_button,]# 设置主窗口的中心部件central_widget = QFrame()central_widget.setLayout(main_layout)self.setCentralWidget(central_widget)def load_image(self):file_path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "图片文件 (*.png *.jpg *.jpeg *.bmp)")if file_path:# 使用Pillow加载图像try:with Image.open(file_path) as img:self.orig_img_data = img.copy() # 保存图像数据到类成员变量if len(self.image_display.saved_rects) > 0: # 清除之前的前景标记self.image_display.saved_rects = []self.image_display.set_image(file_path) # 更新显示except Exception as e:QMessageBox.critical(self, "错误", f"无法加载图像: {e}")def show_orig_image(self):if self.orig_img_data is None:QMessageBox.warning(self, "警告", "请先载入图片")return# 手动将 Pillow 的 Image 对象转换为 QPixmap 对象image_data = self.orig_img_data.convert("RGBA")width, height = image_data.sizebytes_per_line = 4 * widthqimage = QImage(image_data.tobytes(), width, height, bytes_per_line, QImage.Format_RGBA8888)pixmap = QPixmap.fromImage(qimage)self.image_display.set_image(pixmap)def validate_input(self):sender = self.sender()if not sender.hasAcceptableInput():sender.clear()def mode_changed(self):sender = self.sender()if sender == self.rgb_mode:self.gray_mode.setChecked(False)# 当切换到 RGB 模式时,灰度输入组清空,不可用self.gray_edit.clear()self.gray_edit.setDisabled(True)self.gray_min_checkbox.setChecked(False)self.gray_min_checkbox.setDisabled(True)# RGB 输入组可用self.rgb_r_edit.setDisabled(False)self.rgb_g_edit.setDisabled(False)self.rgb_b_edit.setDisabled(False)self.rgb_r_min.setDisabled(False)self.rgb_g_min.setDisabled(False)self.rgb_b_min.setDisabled(False)elif sender == self.gray_mode:self.rgb_mode.setChecked(False)# 当切换到灰度模式时,RGB 输入组清空,不可用self.rgb_r_edit.clear()self.rgb_g_edit.clear()self.rgb_b_edit.clear()self.rgb_r_min.setChecked(False)self.rgb_g_min.setChecked(False)self.rgb_b_min.setChecked(False)self.rgb_r_edit.setDisabled(True)self.rgb_g_edit.setDisabled(True)self.rgb_b_edit.setDisabled(True)self.rgb_r_min.setDisabled(True)self.rgb_g_min.setDisabled(True)self.rgb_b_min.setDisabled(True)# 灰度输入组可用self.gray_edit.setDisabled(False)self.gray_min_checkbox.setDisabled(False)def mark_front(self):if self.orig_img_data is None:QMessageBox.warning(self, "警告", "请先载入图片")return# 清除此前已经标记的前景区self.image_display.front_areas.clear()self.image_display.saved_rects.clear()self.image_display.update()# 禁用除图片显示控件外的所有控件self.disable_controls()self.image_display.setDisabled(False)self.image_display.setFocusPolicy(Qt.StrongFocus)# 弹出提示对话框QMessageBox.information(self, "提示", "鼠标右键点击图片可以退出前景标记模式,再次点击可再次标记前景")# 进入前景标记模式self.image_display.enter_screenshot_mode()# 连接信号,在前景标记完成后恢复控件状态self.image_display.screenshot_finished.connect(self.enable_controls)def disable_controls(self):"""禁用除图片显示控件外的所有控件"""for control in self.controls_to_disable:control.setEnabled(False)def enable_controls(self):"""恢复所有控件的可用性"""for control in self.controls_to_disable:control.setEnabled(True)# 断开信号连接,避免重复触发self.image_display.screenshot_finished.disconnect(self.enable_controls)def clear_background(self):if self.orig_img_data is None:QMessageBox.warning(self, "警告", "请先载入图片")return# 确保图像为 RGBA 模式img = self.orig_img_data.convert("RGBA")img_array = np.array(img) # 转换为 NumPy 数组r, g, b, a = img_array[..., 0], img_array[..., 1], img_array[..., 2], img_array[..., 3]# 创建前景掩码front_mask = np.zeros((img_array.shape[0], img_array.shape[1]), dtype=bool)for rect in self.image_display.front_areas:x1, y1, x2, y2 = rect.getCoords()front_mask[y1:y2, x1:x2] = True# 清除背景逻辑(排除前景区域)if self.rgb_mode.isChecked():# 获取 RGB 输入值r_val = int(self.rgb_r_edit.text()) if self.rgb_r_edit.text() else 0g_val = int(self.rgb_g_edit.text()) if self.rgb_g_edit.text() else 0b_val = int(self.rgb_b_edit.text()) if self.rgb_b_edit.text() else 0r_condition = r >= r_val if self.rgb_r_min.isChecked() else r < r_valg_condition = g >= g_val if self.rgb_g_min.isChecked() else g < g_valb_condition = b >= b_val if self.rgb_b_min.isChecked() else b < b_valmask = r_condition & g_condition & b_condition & ~front_maskimg_array[mask, 3] = 0 # 将 Alpha 通道设置为 0(透明)elif self.gray_mode.isChecked():gray_img = self.orig_img_data.convert("L")gray_array = np.array(gray_img)gray_val = int(self.gray_edit.text()) if self.gray_edit.text() else 0# 进行灰度比较,将图像数据数组转换为根据条件比较结果设置值的布尔数组gray_condition = gray_array >= gray_val if self.gray_min_checkbox.isChecked() else gray_array < gray_val# 前景掩码按位求反,再与灰度条件数组进行按位与运算mask = gray_condition & ~front_mask# 掩码图片中值为 True 的像素位置的 Alpha 通道值设置为 0,从而使这些像素变为透明img_array[mask, 3] = 0 # 将 Alpha 通道设置为 0(透明)# 更新处理后的图像self.nobg_img_data = Image.fromarray(img_array)self.image_display.set_image(self.nobg_img_data.toqpixmap())# 清除此前已经标记的前景区self.image_display.front_areas.clear()self.image_display.saved_rects.clear()self.image_display.update()QMessageBox.information(self, "提示", "背景清除完成")def save_result(self):if self.nobg_img_data is None:QMessageBox.warning(self, "警告", "没有可保存的图像,请先清除背景")returnfile_path, _ = QFileDialog.getSaveFileName(self, "保存结果", "", "PNG 文件 (*.png)")if file_path:try:# 保存图像到指定路径self.nobg_img_data.save(file_path, format="PNG")QMessageBox.information(self, "提示", "图像已成功保存")except Exception as e:QMessageBox.critical(self, "错误", f"保存图像失败: {e}")if __name__ == "__main__":app = QApplication(sys.argv)window = MainWindow()window.show()sys.exit(app.exec_())
程序运行效果:
1、图片的前景区有与白色背景十分类似的颜色,标记前景区予以保护:
2、清除背景后:
3、美女肤白,如果不进行前景色保护,那脸蛋都要透明☺:
如果不是我为保护隐私给她带上的黑眼罩,都要变成空有一张嘴的人了☺。当然,功能还可以进一步改进,加上直接换背景的功能,各位如有意,不妨一试。