基于二维码的图像正射矫正工具 —— 用于三维建模辅助

拍照辅助三维建模 → 角度不垂直 → 用二维码做标定 → 矫正成正射图 → 用于描边、测量、辅助建模。


基于二维码的图像正射矫正工具——用于三维建模辅助

在使用手机或相机拍摄物体进行三维建模辅助时,经常会遇到拍摄角度无法完全垂直的问题,导致照片存在透视畸变,无法直接用于图像描边、尺寸测量、正射底图等后续建模工作。

为了解决这个问题,我基于开源二维码矫正逻辑,开发了这款二维码辅助图像正射矫正工具,可以快速将倾斜拍摄的图片矫正为无透视畸变的正射图,大幅提升建模前期素材质量。

工具用途与背景

  • 拍摄物体用于三维建模、手绘描边、尺寸标注、平面重建
  • 手机/相机难以做到绝对垂直拍摄,图像存在透视变形
  • 在拍摄场景中放入二维码作为标定参照物
  • 通过二维码定位 + 透视变换,输出标准化正射图像
  • 矫正后的图片可直接用于:
    • 正射底图
    • 轮廓描边
    • 尺寸测量
    • 建模参考

核心功能

  1. 二维码标定 + 图像正射矫正
    利用画面中的二维码做坐标标定,自动计算透视变换矩阵,将倾斜照片矫正为垂直视角的正射图。

  2. 批量图片处理
    支持多选图片批量矫正,适合建模素材批量预处理。

  3. 可视化界面(扁平简约风格)

    • 选择图片
    • 开始处理
    • 清空列表
    • 打开文件位置
      全程图形化操作,无需命令行。
  4. 详细信息列表展示

    • 文件名
    • 文件路径
    • 文件大小
    • 处理状态(成功 ✅ / 失败 ❌)
  5. 实时进度条 + 多线程防卡顿
    处理图片时界面不会卡死,进度实时可见。

  6. 自动保存到原文件夹
    输出文件命名:原文件名_corrected.png,方便整理建模素材。

实现原理

  1. 二维码检测
    使用 pyzbar 识别图像中的二维码,获取四个角点坐标。

  2. 角点排序
    通过坐标和与差值排序,确定左上、右上、右下、左下四个顶点。

  3. 透视变换矫正
    使用 OpenCV 计算投影变换矩阵,将图像矫正为正射投影图,消除拍摄角度带来的畸变。

  4. GUI 界面与多线程
    基于 tkinter + ttkbootstrap 构建界面,多线程处理耗时任务,保证流畅操作。

使用流程

  1. 拍摄物体时,在场景内放入二维码作为标定参照物
  2. 使用本工具选择图片
  3. 一键批量矫正为正射图
  4. 直接使用矫正后的图片进行:
    • 描线建模
    • 尺寸测量
    • 平面重建
    • 纹理校正

环境依赖

pip install opencv-python numpy pyzbar ttkbootstrap pillow

代码来源说明

本项目核心二维码矫正逻辑参考并改进自开源代码:
https://github.com/wywzxxz/qrcoderectification/blob/main/main.ipynb

在原版基础上,我进行了工程化扩展

  • 封装为可批量处理的函数
  • 增加完整 GUI 可视化界面
  • 添加多线程、进度条、文件列表、状态显示
  • 支持一键打开输出文件夹
  • 优化异常处理与工具稳定性

完整代码

import cv2
import numpy as np
import os
import threading
import tkinter as tk
from tkinter import ttk
import ttkbootstrap as ttkb
from ttkbootstrap.constants import *
from tkinter import filedialog, messagebox
from pyzbar import pyzbar
from PIL import Image, ImageTk
import math
import subprocess  # 新增:跨平台打开文件夹


def order_points(pts):
    """排序二维码的四个顶点(左上、右上、右下、左下)"""
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]
    return rect


def get_file_size(file_path):
    """获取文件大小,转换为KB/MB单位"""
    size = os.path.getsize(file_path)
    if size < 1024:
        return f"{size} B"
    elif size < 1024 * 1024:
        return f"{round(size / 1024, 2)} KB"
    else:
        return f"{round(size / (1024 * 1024), 2)} MB"


def correct_qrcode(image_path):
    """矫正单张图片中的二维码,返回(是否成功, 错误信息/保存路径)"""
    try:
        image = cv2.imread(image_path)
        if image is None:
            return False, "图片读取失败"
        
        decoded_objects = pyzbar.decode(image)
        if len(decoded_objects) == 0:
            return False, "未识别到二维码"
        
        pts = np.array(decoded_objects[0].polygon, dtype=np.int32)
        rect = order_points(pts)
        (tl, tr, br, bl) = rect
        
        width = np.sqrt(((tr[0] - tl[0])**2) + ((tr[1] - tl[1])**2))
        dst = np.array([
            [tl[0], tl[1]],
            [tl[0] + width - 1, tl[1]],
            [tl[0] + width - 1, tl[1] + width - 1],
            [tl[0], tl[1] + width - 1]
        ], dtype="float32")
        
        orig_height, orig_wid = image.shape[:2]
        M = cv2.getPerspectiveTransform(rect, dst)
        warped = cv2.warpPerspective(src=image, M=M, dsize=(orig_wid, orig_height))
        
        # 保存到原文件夹
        dir_name = os.path.dirname(image_path)
        file_name = os.path.splitext(os.path.basename(image_path))[0]
        save_path = os.path.join(dir_name, f"{file_name}_corrected.png")
        cv2.imwrite(save_path, warped)
        
        return True, save_path
    
    except Exception as e:
        return False, str(e)


def open_file_location(file_path):
    """跨平台打开文件所在文件夹并定位到文件"""
    try:
        if not os.path.exists(file_path):
            messagebox.showwarning("提示", "文件不存在!")
            return
        
        # 不同系统的打开方式
        if os.name == 'nt':  # Windows
            os.startfile(os.path.dirname(file_path))
        elif os.name == 'posix':  # Mac/Linux
            if 'darwin' in os.uname().sysname.lower():  # Mac
                subprocess.run(['open', '-R', file_path])
            else:  # Linux
                subprocess.run(['xdg-open', os.path.dirname(file_path)])
        # messagebox.showinfo("提示", f"已打开文件所在文件夹:\n{os.path.dirname(file_path)}")
    except Exception as e:
        messagebox.showerror("错误", f"打开文件夹失败:{str(e)}")


class QRCodeCorrectorApp:
    def __init__(self, root):
        self.root = root
        self.root.title("二维码矫正工具")
        self.root.geometry("850x600")
        self.root.resizable(False, False)
        
        # 存储选中的图片信息:{索引: (文件名, 路径, 大小, 状态, 保存路径)}
        self.image_list = []
        self.current_index = 0
        self.success_save_paths = []  # 存储处理成功的文件保存路径
        
        # 初始化界面
        self._setup_ui()
    
    def _setup_ui(self):
        """搭建界面布局"""
        # 1. 顶部插画+标题区域
        top_frame = ttkb.Frame(self.root, bootstyle=LIGHT)
        top_frame.pack(fill=X, padx=10, pady=10)
        
        # 简约二维码插画(用PIL绘制)
        illustration = self._create_qr_illustration()
        ill_label = ttkb.Label(top_frame, image=illustration)
        ill_label.image = illustration
        ill_label.pack(side=LEFT, padx=20)
        
        # 标题
        title_label = ttkb.Label(
            top_frame, 
            text="二维码矫正工具", 
            font=("微软雅黑", 18, "bold"),
            bootstyle=PRIMARY
        )
        title_label.pack(side=LEFT, padx=20, pady=10)
        
        # 2. 按钮区域
        btn_frame = ttkb.Frame(self.root, bootstyle=LIGHT)
        btn_frame.pack(fill=X, padx=20, pady=5)
        
        self.select_btn = ttkb.Button(
            btn_frame,
            text="选择图片",
            command=self._select_images,
            bootstyle=SUCCESS,
            width=15
        )
        self.select_btn.pack(side=LEFT, padx=5)
        
        self.process_btn = ttkb.Button(
            btn_frame,
            text="开始处理",
            command=self._start_process_thread,
            bootstyle=PRIMARY,
            width=15
        )
        self.process_btn.pack(side=LEFT, padx=5)
        
        self.clear_btn = ttkb.Button(
            btn_frame,
            text="清空列表",
            command=self._clear_list,
            bootstyle=SECONDARY,
            width=15
        )
        self.clear_btn.pack(side=LEFT, padx=5)
        
        # 新增:打开文件位置按钮(初始禁用)
        self.open_folder_btn = ttkb.Button(
            btn_frame,
            text="打开文件位置",
            command=self._open_selected_file_location,
            bootstyle=INFO,
            width=15,
            state=DISABLED  # 初始禁用
        )
        self.open_folder_btn.pack(side=LEFT, padx=5)
        
        # 3. 进度条
        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttkb.Progressbar(
            self.root,
            variable=self.progress_var,
            maximum=100,
            bootstyle=SUCCESS
        )
        self.progress_bar.pack(fill=X, padx=20, pady=10)
        
        # 4. 图片信息列表
        list_frame = ttkb.Frame(self.root)
        list_frame.pack(fill=BOTH, expand=True, padx=20, pady=5)
        
        # 列表表头
        columns = ("文件名", "路径", "大小", "处理状态")
        self.tree = ttkb.Treeview(
            list_frame,
            columns=columns,
            show="headings",
            bootstyle=LIGHT
        )
        
        # 设置列宽和表头
        self.tree.heading("文件名", text="文件名")
        self.tree.heading("路径", text="文件路径")
        self.tree.heading("大小", text="文件大小")
        self.tree.heading("处理状态", text="处理状态")
        
        self.tree.column("文件名", width=150)
        self.tree.column("路径", width=400)
        self.tree.column("大小", width=80)
        self.tree.column("处理状态", width=80)
        
        # 滚动条
        scrollbar = ttkb.Scrollbar(
            list_frame,
            orient=VERTICAL,
            command=self.tree.yview
        )
        self.tree.configure(yscrollcommand=scrollbar.set)
        
        self.tree.pack(side=LEFT, fill=BOTH, expand=True)
        scrollbar.pack(side=RIGHT, fill=Y)
    
    def _create_qr_illustration(self):
        """绘制简约二维码插画(扁平风格)"""
        # 创建空白画布
        img = Image.new("RGB", (80, 80), (255, 255, 255))
        draw = ImageDraw.Draw(img)
        
        # 绘制二维码定位角(三个正方形)
        # 左上
        draw.rectangle((5, 5, 20, 20), fill=(0, 0, 0))
        draw.rectangle((8, 8, 17, 17), fill=(255, 255, 255))
        # 右上
        draw.rectangle((60, 5, 75, 20), fill=(0, 0, 0))
        draw.rectangle((63, 8, 72, 17), fill=(255, 255, 255))
        # 左下
        draw.rectangle((5, 60, 20, 75), fill=(0, 0, 0))
        draw.rectangle((8, 63, 17, 72), fill=(255, 255, 255))
        
        # 绘制随机小方块(模拟二维码点阵)
        for i in range(30):
            x = random.randint(25, 55)
            y = random.randint(25, 55)
            draw.rectangle((x, y, x+2, y+2), fill=(0, 0, 0))
        
        # 转换为tkinter可用格式
        img = img.resize((80, 80), Image.Resampling.LANCZOS)
        return ImageTk.PhotoImage(img)
    
    def _select_images(self):
        """选择多张图片并添加到列表"""
        file_types = [
            ("图片文件", "*.jpg *.jpeg *.png *.bmp *.tiff"),
            ("所有文件", "*.*")
        ]
        file_paths = filedialog.askopenfilenames(title="选择需要处理的图片", filetypes=file_types)
        if not file_paths:
            return
        
        # 清空原有列表(可选,也可追加)
        self._clear_list()
        
        # 添加新选中的图片到列表
        for path in file_paths:
            file_name = os.path.basename(path)
            file_size = get_file_size(path)
            # 新增保存路径字段,初始为空
            self.image_list.append((file_name, path, file_size, "未处理", ""))
            # 插入到Treeview
            self.tree.insert("", END, values=(file_name, path, file_size, "未处理"))
        
        # 重置进度条
        self.progress_var.set(0)
        # 禁用打开文件按钮
        self.open_folder_btn.config(state=DISABLED)
    
    def _clear_list(self):
        """清空图片列表"""
        self.image_list = []
        self.success_save_paths = []  # 清空成功路径列表
        for item in self.tree.get_children():
            self.tree.delete(item)
        self.progress_var.set(0)
        # 禁用打开文件按钮
        self.open_folder_btn.config(state=DISABLED)
    
    def _start_process_thread(self):
        """启动多线程处理图片,避免界面卡顿"""
        if not self.image_list:
            messagebox.showwarning("提示", "请先选择需要处理的图片!")
            return
        
        # 清空成功路径列表
        self.success_save_paths = []
        
        # 禁用按钮,防止重复点击
        self.process_btn.config(state=DISABLED)
        self.select_btn.config(state=DISABLED)
        self.open_folder_btn.config(state=DISABLED)
        
        # 启动子线程处理
        process_thread = threading.Thread(target=self._process_images)
        process_thread.daemon = True  # 主线程退出时子线程也退出
        process_thread.start()
    
    def _process_images(self):
        """批量处理图片(在子线程中执行)"""
        total = len(self.image_list)
        for idx, (file_name, path, size, _, _) in enumerate(self.image_list):
            # 处理单张图片
            success, msg = correct_qrcode(path)
            
            # 更新状态(必须在主线程中操作UI)
            self.root.after(0, self._update_item_status, idx, success, msg)
            
            # 更新进度条
            progress = (idx + 1) / total * 100
            self.root.after(0, self.progress_var.set, progress)
        
        # 处理完成后恢复按钮状态(主线程)
        self.root.after(0, self._process_complete)
    
    def _update_item_status(self, idx, success, msg):
        """更新列表中图片的处理状态"""
        # 更新内存中的状态
        if success:
            status = "✅ 成功"
            self.success_save_paths.append(msg)  # 保存成功的文件路径
            self.image_list[idx] = (self.image_list[idx][0], self.image_list[idx][1], self.image_list[idx][2], status, msg)
        else:
            status = f"❌ 失败:{msg}"
            self.image_list[idx] = (self.image_list[idx][0], self.image_list[idx][1], self.image_list[idx][2], status, "")
        
        # 更新Treeview显示
        item = self.tree.get_children()[idx]
        self.tree.item(item, values=(
            self.image_list[idx][0],
            self.image_list[idx][1],
            self.image_list[idx][2],
            self.image_list[idx][3]
        ))
    
    def _process_complete(self):
        """处理完成后的回调"""
        self.process_btn.config(state=NORMAL)
        self.select_btn.config(state=NORMAL)
        # 如果有成功处理的文件,启用打开文件按钮
        if self.success_save_paths:
            self.open_folder_btn.config(state=NORMAL)
        # messagebox.showinfo("完成", "所有图片处理完毕!")
    
    def _open_selected_file_location(self):
        """打开文件位置的回调函数"""
        if not self.success_save_paths:
            messagebox.showwarning("提示", "暂无处理成功的文件!")
            return
        # 打开第一个成功处理的文件所在文件夹
        open_file_location(self.success_save_paths[0])


# 补充缺失的导入(PIL的ImageDraw和random)
from PIL import ImageDraw
import random

if __name__ == "__main__":
    # 使用ttkbootstrap的扁平主题
    root = ttkb.Window(themename="flatly")
    app = QRCodeCorrectorApp(root)
    root.mainloop()

运行效果

image

image

posted @ 2026-03-14 04:10  Dapenson  阅读(2)  评论(0)    收藏  举报