抠图换背景工具 v2.0(OpenCV GrabCut 版)
纯 OpenCV 实现的抠图换背景工具,不需要 AI 模型下载,pip install 三个包即可运行。
作者
安装与运行
pip install PyQt5 opencv-python Pillow numpypython ysp/code/photo_tool_cv.py
不需要下载任何 AI 模型,不需要 rembg/onnxruntime,装完直接用。
与 v1.0(rembg版)对比
| | |
|---|
| | |
| | |
| | pip install opencv-python |
| | |
| | |
| | |
界面布局
+------------------+-------------------+-------------------+| 上传图片 | 原图 | 结果 || [进度条] | +---------------+ | +---------------+ || | | | | | | || 抠图精度 | | 原始图片 | | | 抠图+换背景 | || 迭代: [===8===] | | | | | | || | +---------------+ | +---------------+ || 背景颜色 | | || [白][红][蓝]... | | || [取色器] | | || [颜色预览] | | || | | || 输出尺寸 | | || [一寸 295x413] | | || 宽:[295] 高:[413]| | || | | || [导出PNG] | | || [导出JPG] | | |+------------------+-------------------+-------------------+
功能说明
GrabCut 抠图
OpenCV 的 GrabCut 算法工作原理:
- 用矩形框住图片中心区域(留5%边距)作为初始前景估计
迭代次数可调(3-15次),次数越多越精细但越慢。默认8次,人像证件照效果好。
背景颜色
输出尺寸
- 10种预设(一寸/二寸/护照/身份证/签证等)+ 自定义
导出
代码架构
photo_tool_cv.py+-- SIZES / BG_COLORS # 预设尺寸和颜色+-- GrabCutWorker(QThread) # 抠图子线程| +-- cv2.grabCut() 两阶段| +-- 矩形初始化 + mask优化| +-- 高斯模糊平滑边缘| +-- 返回 RGBA PIL Image+-- pil2pixmap() # PIL -> QPixmap+-- compose() # 前景+背景色+尺寸 -> 合成+-- Preview(QLabel) # 棋盘格背景预览+-- PhotoToolCV(QMainWindow) # 主窗口 +-- _upload() # 上传 -> 启动GrabCut线程 +-- _on_done() # 抠图完成 -> 刷新预览 +-- _pick_color() # 取色器 +-- _refresh() # 合成预览 +-- _export() # 导出PNG/JPG
GrabCut 核心代码
defrun(self): img = cv2.imread(path) h, w = img.shape[:2]# 第一阶段:矩形初始化 rect = (边距, 边距, w-2*边距, h-2*边距) mask = np.zeros((h, w), np.uint8) cv2.grabCut(img, mask, rect, bgd, fgd, 8, GC_INIT_WITH_RECT)# 第二阶段:中心区域标记为可能前景,再优化 mask[中心区域] = 标记为可能前景 cv2.grabCut(img, mask, None, bgd, fgd, 3, GC_INIT_WITH_MASK)# 生成前景mask + 平滑边缘 fg_mask = (mask==前景 | mask==可能前景) -> 255 fg_mask = GaussianBlur(fg_mask)# 合成RGBA rgba = [R, G, B, fg_mask]
预设尺寸
使用技巧
- 如果抠图有残留,可以先用图片编辑器裁剪掉多余背景再上传
依赖
PyQt5opencv-pythonPillownumpy
全部 pip install 即可,无需额外下载。
-- coding: utf-8 --"""抠图换背景工具 v2.0 - PyQt5 + OpenCV GrabCut无需AI模型,纯OpenCV实现,pip install 即用作者:杨少平 | 公众号:Python学在坚持运行:python photo_tool_cv.py依赖:pip install PyQt5 opencv-python Pillow numpy"""import sys, osimport 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, QSlider)from PyQt5.QtCore import Qt, pyqtSignal, QThreadfrom PyQt5.QtGui import QFont, QColor, QPixmap, QImage, QPainterSIZES = [("一寸 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"), ("灰色", "#CCCCCC"), ("透明", "transparent"),]class GrabCutWorker(QThread):"""OpenCV GrabCut 抠图线程"""finished = pyqtSignal(object)def __init__(self, img_path, iterations=8): super().__init__() self.img_path = img_path self.iterations = iterationsdef run(self): try: img = cv2.imread(self.img_path) if img is None: self.finished.emit(None); return h, w = img.shape[:2] # 初始矩形:留5%边距框住主体 mx, my = max(1, int(w * 0.05)), max(1, int(h * 0.02)) rect = (mx, my, w - 2 * mx, h - 2 * my) mask = np.zeros((h, w), np.uint8) bgd = np.zeros((1, 65), np.float64) fgd = np.zeros((1, 65), np.float64) cv2.grabCut(img, mask, rect, bgd, fgd, self.iterations, cv2.GC_INIT_WITH_RECT) # 中心区域标记为可能前景,再迭代优化 cy1, cy2 = int(h * 0.1), int(h * 0.9) cx1, cx2 = int(w * 0.2), int(w * 0.8) mask[cy1:cy2, cx1:cx2] = np.where(mask[cy1:cy2, cx1:cx2] == 0, 3, mask[cy1:cy2, cx1:cx2]) cv2.grabCut(img, mask, None, bgd, fgd, 3, cv2.GC_INIT_WITH_MASK) # 前景mask fg = np.where((mask == 1) | (mask == 3), 255, 0).astype(np.uint8) fg = cv2.GaussianBlur(fg, (3, 3), 0) # 转RGBA rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) rgba = np.dstack([rgb, fg]) self.finished.emit(Image.fromarray(rgba, "RGBA")) except Exception as e: print(f"GrabCut error: {e}") self.finished.emit(None)def pil2pixmap(pil_img):if pil_img.mode == "RGBA":data = pil_img.tobytes("raw", "RGBA")qi = QImage(data, pil_img.width, pil_img.height, QImage.Format_RGBA8888)else:data = pil_img.tobytes("raw", "RGB")qi = QImage(data, pil_img.width, pil_img.height, QImage.Format_RGB888)return QPixmap.fromImage(qi)def compose(fg, bg_color, w, h):fg_copy = fg.copy()fg_copy.thumbnail((w, h), Image.LANCZOS)fw, fh = fg_copy.sizeif bg_color == "transparent":bg = Image.new("RGBA", (w, h), (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", (w, h), (r, g, b, 255))bg.paste(fg_copy, ((w - fw) // 2, (h - fh) // 2), fg_copy)return bgclass Preview(QLabel):def init(self, text=""):super().init()self.setAlignment(Qt.AlignCenter)self.setMinimumSize(220, 280)self._pix = Noneself._text = text or "上传图片"def set_image(self, pix): self._pix = pix; self.update()def paintEvent(self, e): p = QPainter(self) t = 10 for y in range(0, self.height(), t): for x in range(0, self.width(), t): c = QColor(225, 225, 225) if (x // t + y // t) % 2 == 0 else QColor(255, 255, 255) p.fillRect(x, y, t, t, c) if self._pix: s = self._pix.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) p.drawPixmap((self.width() - s.width()) // 2, (self.height() - s.height()) // 2, s) else: p.setPen(QColor(160, 160, 160)); p.setFont(QFont("Microsoft YaHei", 12)) p.drawText(self.rect(), Qt.AlignCenter, self._text) p.end()class PhotoToolCV(QMainWindow):def init(self):super().init()self.setWindowTitle("📷 抠图换背景 v2.0 (OpenCV) | Python学在坚持")self.setMinimumSize(860, 560)self.resize(960, 630)self.fg_rgba = Noneself.bg_color = "#FFFFFF"self.worker = Noneself._build()def _build(self): c = QWidget(); self.setCentralWidget(c) root = QHBoxLayout(c); 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_up = QPushButton("📂 上传图片") btn_up.setStyleSheet("padding:12px;background:#1976d2;color:#fff;border:none;border-radius:6px;font-size:14px;font-weight:bold;") btn_up.clicked.connect(self._upload) ll.addWidget(btn_up) self.prog = QProgressBar(); self.prog.setMaximumHeight(6); self.prog.setTextVisible(False) self.prog.setRange(0, 0); self.prog.hide() ll.addWidget(self.prog) # 迭代次数 g0 = QGroupBox("⚙ 抠图精度") g0l = QHBoxLayout() g0l.addWidget(QLabel("迭代:")) self.sl_iter = QSlider(Qt.Horizontal); self.sl_iter.setRange(3, 15); self.sl_iter.setValue(8) self.lbl_iter = QLabel("8") self.sl_iter.valueChanged.connect(lambda v: self.lbl_iter.setText(str(v))) g0l.addWidget(self.sl_iter, 1); g0l.addWidget(self.lbl_iter) g0.setLayout(g0l) ll.addWidget(g0) # 背景色 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_sel) g1l.addWidget(self.cb_color) cr = QHBoxLayout() for name, hx in BG_COLORS: if hx == "transparent": continue b = QPushButton(); b.setFixedSize(28, 28) b.setStyleSheet(f"background:{hx};border:2px solid #ccc;border-radius:14px;") b.setToolTip(name); b.clicked.connect(lambda _, c=hx: self._set_bg(c)) cr.addWidget(b) g1l.addLayout(cr) btn_pick = QPushButton("🎨 取色器"); btn_pick.clicked.connect(self._pick_color) g1l.addWidget(btn_pick) self.color_box = QFrame(); self.color_box.setFixedHeight(28) self.color_box.setStyleSheet("background:#FFFFFF;border:1px solid #ccc;border-radius:4px;") g1l.addWidget(self.color_box) 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_sel) g2l.addWidget(self.cb_size) sr = QHBoxLayout() sr.addWidget(QLabel("宽:")) self.sp_w = QSpinBox(); self.sp_w.setRange(50, 5000); self.sp_w.setValue(295) self.sp_w.valueChanged.connect(self._refresh); sr.addWidget(self.sp_w) sr.addWidget(QLabel("高:")) self.sp_h = QSpinBox(); self.sp_h.setRange(50, 5000); self.sp_h.setValue(413) self.sp_h.valueChanged.connect(self._refresh); sr.addWidget(self.sp_h) g2l.addLayout(sr); g2.setLayout(g2l); ll.addWidget(g2) # 导出 g3 = QGroupBox("💾 导出") g3l = QVBoxLayout() bp = QPushButton("导出 PNG"); bp.setStyleSheet("padding:8px;background:#388e3c;color:#fff;border:none;border-radius:4px;") bp.clicked.connect(lambda: self._export("png")); g3l.addWidget(bp) bj = QPushButton("导出 JPG"); bj.setStyleSheet("padding:8px;background:#1976d2;color:#fff;border:none;border-radius:4px;") bj.clicked.connect(lambda: self._export("jpg")); g3l.addWidget(bj) g3.setLayout(g3l); ll.addWidget(g3) self.lbl_st = QLabel("就绪"); self.lbl_st.setStyleSheet("color:#888;font-size:11px;") ll.addWidget(self.lbl_st); ll.addStretch() root.addWidget(left) # 右侧预览 right = QWidget(); rl = QVBoxLayout(right); rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(6) lr = QHBoxLayout(); lr.addWidget(QLabel("📷 原图")); lr.addWidget(QLabel("✅ 结果")); rl.addLayout(lr) pr = QHBoxLayout() self.pv_orig = Preview("上传图片"); self.pv_result = Preview("抠图结果") pr.addWidget(self.pv_orig); pr.addWidget(self.pv_result) rl.addLayout(pr, 1); root.addWidget(right, 1) self.statusBar().showMessage("公众号: Python学在坚持 | 微信: ysp2338084 | 作者: 杨少平")def _upload(self): p, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "图片 (*.png *.jpg *.jpeg *.bmp *.webp);;所有 (*)") if not p: return self.pv_orig.set_image(pil2pixmap(Image.open(p).convert("RGB"))) self.lbl_st.setText("⏳ GrabCut 抠图中...") self.prog.show() self.worker = GrabCutWorker(p, self.sl_iter.value()) self.worker.finished.connect(self._on_done) self.worker.start()def _on_done(self, rgba): self.prog.hide() if rgba is None: self.lbl_st.setText("❌ 抠图失败"); QMessageBox.warning(self, "失败", "抠图失败"); return self.fg_rgba = rgba self.lbl_st.setText(f"✅ 抠图完成(迭代{self.sl_iter.value()}次)") self._refresh()def _on_color_sel(self, i): if 0 <= i < len(BG_COLORS): self._set_bg(BG_COLORS[i][1])def _set_bg(self, c): self.bg_color = c if c == "transparent": self.color_box.setStyleSheet("background:repeating-conic-gradient(#ddd 0% 25%,#fff 0% 50%) 50%/10px 10px;border:1px solid #ccc;border-radius:4px;") else: self.color_box.setStyleSheet(f"background:{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 "#FFF"), self) if c.isValid(): self._set_bg(c.name())def _on_size_sel(self, i): if 0 <= i < len(SIZES): _, w, h = SIZES[i] if w > 0: self.sp_w.setValue(w) if h > 0: self.sp_h.setValue(h)def _refresh(self): if not self.fg_rgba: return r = compose(self.fg_rgba, self.bg_color, self.sp_w.value(), self.sp_h.value()) self.pv_result.set_image(pil2pixmap(r))def _export(self, fmt): if not self.fg_rgba: QMessageBox.warning(self, "提示", "请先上传抠图"); return r = compose(self.fg_rgba, self.bg_color, self.sp_w.value(), self.sp_h.value()) ext = "PNG (*.png)" if fmt == "png" else "JPEG (*.jpg)" p, _ = QFileDialog.getSaveFileName(self, "导出", f"photo.{fmt}", ext) if not p: return if fmt == "jpg": r = r.convert("RGB") r.save(p, quality=95) self.lbl_st.setText(f"✅ 已导出: {os.path.basename(p)}") QMessageBox.information(self, "成功", f"已保存: {p}\n尺寸: {self.sp_w.value()}x{self.sp_h.value()}")if name == "main":app = QApplication(sys.argv)app.setFont(QFont("Microsoft YaHei", 10))win = PhotoToolCV()win.show()sys.exit(app.exec_())