如何利用二维码图像实现三维建模辅助的正射矫正?

摘要:拍照辅助三维建模 → 角度不垂直 → 用二维码做标定 → 矫正成正射图 → 用于描边、测量、辅助建模。 基于二维码的图像正射矫正工具——用于三维建模辅助 在使用手机或相机拍摄物体进行三维建模辅助时,经常会遇到拍摄角度无法完全垂直的问题,导致
拍照辅助三维建模 → 角度不垂直 → 用二维码做标定 → 矫正成正射图 → 用于描边、测量、辅助建模。 基于二维码的图像正射矫正工具——用于三维建模辅助 在使用手机或相机拍摄物体进行三维建模辅助时,经常会遇到拍摄角度无法完全垂直的问题,导致照片存在透视畸变,无法直接用于图像描边、尺寸测量、正射底图等后续建模工作。 为了解决这个问题,我基于开源二维码矫正逻辑,开发了这款二维码辅助图像正射矫正工具,可以快速将倾斜拍摄的图片矫正为无透视畸变的正射图,大幅提升建模前期素材质量。 工具用途与背景 拍摄物体用于三维建模、手绘描边、尺寸标注、平面重建 手机/相机难以做到绝对垂直拍摄,图像存在透视变形 在拍摄场景中放入二维码作为标定参照物 通过二维码定位 + 透视变换,输出标准化正射图像 矫正后的图片可直接用于: 正射底图 轮廓描边 尺寸测量 建模参考 核心功能 二维码标定 + 图像正射矫正 利用画面中的二维码做坐标标定,自动计算透视变换矩阵,将倾斜照片矫正为垂直视角的正射图。 批量图片处理 支持多选图片批量矫正,适合建模素材批量预处理。 可视化界面(扁平简约风格) 选择图片 开始处理 清空列表 打开文件位置 全程图形化操作,无需命令行。 详细信息列表展示 文件名 文件路径 文件大小 处理状态(成功 ✅ / 失败 ❌) 实时进度条 + 多线程防卡顿 处理图片时界面不会卡死,进度实时可见。 自动保存到原文件夹 输出文件命名:原文件名_corrected.png,方便整理建模素材。 实现原理 二维码检测 使用 pyzbar 识别图像中的二维码,获取四个角点坐标。 角点排序 通过坐标和与差值排序,确定左上、右上、右下、左下四个顶点。 透视变换矫正 使用 OpenCV 计算投影变换矩阵,将图像矫正为正射投影图,消除拍摄角度带来的畸变。 GUI 界面与多线程 基于 tkinter + ttkbootstrap 构建界面,多线程处理耗时任务,保证流畅操作。 使用流程 拍摄物体时,在场景内放入二维码作为标定参照物 使用本工具选择图片 一键批量矫正为正射图 直接使用矫正后的图片进行: 描线建模 尺寸测量 平面重建 纹理校正 环境依赖 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() 运行效果