-- coding: utf-8 --"""证件照/抠图换背景工具 v1.0 - PyQt5功能:上传图片 -> 自动抠图 -> 换背景色 -> 自定义尺寸 -> 导出作者:杨少平 | 公众号:Python学在坚持运行:python photo_tool.py依赖:pip install PyQt5 opencv-python Pillow numpy"""import sys, os, ioimport numpy as npimport cv2from PIL import Imagefrom PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QLabel, QPushButton, QComboBox, QColorDialog, QFileDialog,QMessageBox, QGroupBox, QSpinBox, QFrame, QProgressBar)from PyQt5.QtCore import Qt, pyqtSignal, QThreadfrom PyQt5.QtGui import QFont, QColor, QPixmap, QImage, QPainter常用证件照尺寸(名称, 宽px, 高px)SIZES = [("一寸 (295x413)", 295, 413),("二寸 (413x579)", 413, 579),("小一寸 (260x378)", 260, 378),("小二寸 (413x531)", 413, 531),("大一寸 (390x567)", 390, 567),("护照 (354x472)", 354, 472),("身份证 (358x441)", 358, 441),("签证 (600x600)", 600, 600),("社交头像 (500x500)", 500, 500),("自定义", 0, 0),]常用背景色BG_COLORS = [("白色", "#FFFFFF"),("红色", "#FF0000"),("蓝色", "#438EDB"),("浅蓝", "#86C8F5"),("渐变蓝", "#2B7FD4"),("灰色", "#CCCCCC"),("透明", "transparent"),]class RemoveBgWorker(QThread):"""抠图子线程 - 使用 OpenCV GrabCut(无需额外AI模型)"""finished = pyqtSignal(object) # PIL Image (RGBA)def __init__(self, img_path): super().__init__() self.img_path = img_pathdef run(self): try: img = cv2.imread(self.img_path) if img is None: self.finished.emit(None) return h, w = img.shape[:2] # GrabCut 需要一个初始矩形框住前景,取中心80%区域 margin_x, margin_y = int(w * 0.05), int(h * 0.02) rect = (margin_x, margin_y, w - 2 * margin_x, h - 2 * margin_y) mask = np.zeros((h, w), np.uint8) bgd_model = np.zeros((1, 65), np.float64) fgd_model = np.zeros((1, 65), np.float64) # 迭代5次 cv2.grabCut(img, mask, rect, bgd_model, fgd_model, 5, cv2.GC_INIT_WITH_RECT) # 再用中心区域作为确定前景,迭代3次优化 center_mask = np.zeros((h, w), np.uint8) cx1, cy1 = int(w * 0.2), int(h * 0.1) cx2, cy2 = int(w * 0.8), int(h * 0.9) center_mask[cy1:cy2, cx1:cx2] = 1 mask[center_mask == 1] = np.where(mask[center_mask == 1] == 0, 3, mask[center_mask == 1]) cv2.grabCut(img, mask, None, bgd_model, fgd_model, 3, cv2.GC_INIT_WITH_MASK) # 生成前景mask fg_mask = np.where((mask == 1) | (mask == 3), 255, 0).astype(np.uint8) # 平滑边缘 fg_mask = cv2.GaussianBlur(fg_mask, (5, 5), 0) # 转RGBA img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) rgba = np.dstack([img_rgb, fg_mask]) pil_img = Image.fromarray(rgba, "RGBA") self.finished.emit(pil_img) except Exception as e: print(f"抠图失败: {e}") self.finished.emit(None)def pil_to_qpixmap(pil_img):"""PIL Image -> QPixmap"""if pil_img.mode == "RGBA":data = pil_img.tobytes("raw", "RGBA")qimg = QImage(data, pil_img.width, pil_img.height, QImage.Format_RGBA8888)else:data = pil_img.tobytes("raw", "RGB")qimg = QImage(data, pil_img.width, pil_img.height, QImage.Format_RGB888)return QPixmap.fromImage(qimg)def compose(fg_rgba, bg_color, width, height):"""合成:前景RGBA + 背景色 -> 指定尺寸RGB图"""# 缩放前景到目标尺寸(保持比例居中)fg = fg_rgba.copy()fg.thumbnail((width, height), Image.LANCZOS)fw, fh = fg.sizeif bg_color == "transparent": bg = Image.new("RGBA", (width, height), (0, 0, 0, 0))else: r, g, b = int(bg_color[1:3], 16), int(bg_color[3:5], 16), int(bg_color[5:7], 16) bg = Image.new("RGBA", (width, height), (r, g, b, 255))# 居中粘贴x = (width - fw) // 2y = (height - fh) // 2bg.paste(fg, (x, y), fg)return bgclass PreviewLabel(QLabel):"""带棋盘格背景的预览控件"""def init(self):super().init()self.setAlignment(Qt.AlignCenter)self.setMinimumSize(200, 260)self.setStyleSheet("border:1px solid #ddd;border-radius:4px;")self._pixmap = Nonedef set_image(self, pixmap): self._pixmap = pixmap self.update()def paintEvent(self, e): p = QPainter(self) # 棋盘格 tile = 10 for y in range(0, self.height(), tile): for x in range(0, self.width(), tile): c = QColor(220, 220, 220) if (x // tile + y // tile) % 2 == 0 else QColor(255, 255, 255) p.fillRect(x, y, tile, tile, c) if self._pixmap: scaled = self._pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) x = (self.width() - scaled.width()) // 2 y = (self.height() - scaled.height()) // 2 p.drawPixmap(x, y, scaled) else: p.setPen(QColor(150, 150, 150)) p.setFont(QFont("Microsoft YaHei", 12)) p.drawText(self.rect(), Qt.AlignCenter, "上传图片") p.end()==================== 主窗口 ====================class PhotoToolApp(QMainWindow):def init(self):super().init()self.setWindowTitle("📷 抠图换背景工具 v1.0 | Python学在坚持")self.setMinimumSize(850, 550)self.resize(950, 620)self.fg_rgba = None # 抠图后的前景RGBAself.bg_color = "#FFFFFF"self.worker = Noneself._build()def _build(self): central = QWidget() self.setCentralWidget(central) root = QHBoxLayout(central) root.setContentsMargins(10, 10, 10, 10) root.setSpacing(10) # ---- 左侧控制 ---- left = QWidget() left.setMaximumWidth(260) ll = QVBoxLayout(left) ll.setContentsMargins(0, 0, 0, 0) ll.setSpacing(8) btn_upload = QPushButton("📂 上传图片") btn_upload.setStyleSheet("padding:12px;background:#1976d2;color:#fff;border:none;border-radius:6px;font-size:14px;font-weight:bold;") btn_upload.clicked.connect(self._upload) ll.addWidget(btn_upload) self.progress = QProgressBar() self.progress.setMaximumHeight(6) self.progress.setTextVisible(False) self.progress.setRange(0, 0) self.progress.hide() ll.addWidget(self.progress) # 背景色 g1 = QGroupBox("🎨 背景颜色") g1l = QVBoxLayout() self.cb_color = QComboBox() for name, _ in BG_COLORS: self.cb_color.addItem(name) self.cb_color.currentIndexChanged.connect(self._on_color_change) g1l.addWidget(self.cb_color) # 颜色预览块 color_row = QHBoxLayout() for name, hex_c in BG_COLORS: if hex_c == "transparent": continue b = QPushButton() b.setFixedSize(28, 28) b.setStyleSheet(f"background:{hex_c};border:2px solid #ccc;border-radius:14px;") b.setToolTip(name) b.clicked.connect(lambda _, c=hex_c: self._set_color(c)) color_row.addWidget(b) g1l.addLayout(color_row) btn_picker = QPushButton("🎨 取色器") btn_picker.setStyleSheet("padding:6px;") btn_picker.clicked.connect(self._pick_color) g1l.addWidget(btn_picker) self.color_preview = QFrame() self.color_preview.setFixedHeight(30) self.color_preview.setStyleSheet("background:#FFFFFF;border:1px solid #ccc;border-radius:4px;") g1l.addWidget(self.color_preview) g1.setLayout(g1l) ll.addWidget(g1) # 尺寸 g2 = QGroupBox("📐 输出尺寸") g2l = QVBoxLayout() self.cb_size = QComboBox() for name, _, _ in SIZES: self.cb_size.addItem(name) self.cb_size.currentIndexChanged.connect(self._on_size_change) g2l.addWidget(self.cb_size) size_row = QHBoxLayout() size_row.addWidget(QLabel("宽:")) self.sp_w = QSpinBox() self.sp_w.setRange(50, 5000) self.sp_w.setValue(295) self.sp_w.valueChanged.connect(self._refresh) size_row.addWidget(self.sp_w) size_row.addWidget(QLabel("高:")) self.sp_h = QSpinBox() self.sp_h.setRange(50, 5000) self.sp_h.setValue(413) self.sp_h.valueChanged.connect(self._refresh) size_row.addWidget(self.sp_h) g2l.addLayout(size_row) g2.setLayout(g2l) ll.addWidget(g2) # 导出 g3 = QGroupBox("💾 导出") g3l = QVBoxLayout() btn_png = QPushButton("导出 PNG(透明背景可用)") btn_png.setStyleSheet("padding:8px;background:#388e3c;color:#fff;border:none;border-radius:4px;") btn_png.clicked.connect(lambda: self._export("png")) btn_jpg = QPushButton("导出 JPG(白底/彩底)") btn_jpg.setStyleSheet("padding:8px;background:#1976d2;color:#fff;border:none;border-radius:4px;") btn_jpg.clicked.connect(lambda: self._export("jpg")) g3l.addWidget(btn_png) g3l.addWidget(btn_jpg) g3.setLayout(g3l) ll.addWidget(g3) self.lbl_status = QLabel("就绪") self.lbl_status.setStyleSheet("color:#888;font-size:11px;") ll.addWidget(self.lbl_status) ll.addStretch() root.addWidget(left) # ---- 右侧预览:原图 + 结果 ---- right = QWidget() rl = QVBoxLayout(right) rl.setContentsMargins(0, 0, 0, 0) rl.setSpacing(6) labels = QHBoxLayout() labels.addWidget(QLabel("📷 原图")) labels.addWidget(QLabel("✅ 结果")) rl.addLayout(labels) preview_row = QHBoxLayout() self.preview_orig = PreviewLabel() self.preview_result = PreviewLabel() preview_row.addWidget(self.preview_orig) preview_row.addWidget(self.preview_result) rl.addLayout(preview_row, 1) root.addWidget(right, 1) self.statusBar().showMessage("公众号: Python学在坚持 | 微信: ysp2338084 | 作者: 杨少平")# ---- 上传 ----def _upload(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "图片 (*.png *.jpg *.jpeg *.bmp *.webp);;所有 (*)") if not path: return # 显示原图 orig = Image.open(path) self.preview_orig.set_image(pil_to_qpixmap(orig.convert("RGB"))) # 开始抠图 self.lbl_status.setText("⏳ 抠图中(GrabCut)...") self.progress.show() self.worker = RemoveBgWorker(path) self.worker.finished.connect(self._on_rembg_done) self.worker.start()def _on_rembg_done(self, rgba_img): self.progress.hide() if rgba_img is None: self.lbl_status.setText("❌ 抠图失败") QMessageBox.critical(self, "失败", "抠图失败,请确认图片格式正确") return self.fg_rgba = rgba_img self.lbl_status.setText("✅ 抠图完成(GrabCut)") self._refresh()# ---- 颜色 ----def _on_color_change(self, idx): if 0 <= idx < len(BG_COLORS): self.bg_color = BG_COLORS[idx][1] if self.bg_color != "transparent": self.color_preview.setStyleSheet(f"background:{self.bg_color};border:1px solid #ccc;border-radius:4px;") else: self.color_preview.setStyleSheet("background:repeating-conic-gradient(#ddd 0% 25%,#fff 0% 50%) 50%/10px 10px;border:1px solid #ccc;border-radius:4px;") self._refresh()def _set_color(self, hex_c): self.bg_color = hex_c self.color_preview.setStyleSheet(f"background:{hex_c};border:1px solid #ccc;border-radius:4px;") self._refresh()def _pick_color(self): c = QColorDialog.getColor(QColor(self.bg_color if self.bg_color != "transparent" else "#FFFFFF"), self, "选择背景色") if c.isValid(): self._set_color(c.name())# ---- 尺寸 ----def _on_size_change(self, idx): if 0 <= idx < len(SIZES): _, w, h = SIZES[idx] if w > 0 and h > 0: self.sp_w.setValue(w) self.sp_h.setValue(h)# ---- 刷新预览 ----def _refresh(self): if self.fg_rgba is None: return w, h = self.sp_w.value(), self.sp_h.value() result = compose(self.fg_rgba, self.bg_color, w, h) self.preview_result.set_image(pil_to_qpixmap(result))# ---- 导出 ----def _export(self, fmt): if self.fg_rgba is None: QMessageBox.warning(self, "提示", "请先上传并抠图"); return w, h = self.sp_w.value(), self.sp_h.value() result = compose(self.fg_rgba, self.bg_color, w, h) ext = "PNG (*.png)" if fmt == "png" else "JPEG (*.jpg)" path, _ = QFileDialog.getSaveFileName(self, "导出图片", f"photo.{fmt}", ext) if not path: return if fmt == "jpg": result = result.convert("RGB") result.save(path, quality=95) self.lbl_status.setText(f"✅ 已导出: {os.path.basename(path)}") QMessageBox.information(self, "成功", f"已保存到:\n{path}\n尺寸: {w}x{h}")==================== 启动 ====================if name == "main":app = QApplication(sys.argv)app.setFont(QFont("Microsoft YaHei", 10))win = PhotoToolApp()win.show()sys.exit(app.exec_())