拍照辅助三维建模 → 角度不垂直 → 用二维码做标定 → 矫正成正射图 → 用于描边、测量、辅助建模。
基于二维码的图像正射矫正工具——用于三维建模辅助
在使用手机或相机拍摄物体进行三维建模辅助时,经常会遇到拍摄角度无法完全垂直的问题,导致照片存在透视畸变,无法直接用于图像描边、尺寸测量、正射底图等后续建模工作。
为了解决这个问题,我基于开源二维码矫正逻辑,开发了这款二维码辅助图像正射矫正工具,可以快速将倾斜拍摄的图片矫正为无透视畸变的正射图,大幅提升建模前期素材质量。
工具用途与背景
- 拍摄物体用于三维建模、手绘描边、尺寸标注、平面重建
- 手机/相机难以做到绝对垂直拍摄,图像存在透视变形
- 在拍摄场景中放入二维码作为标定参照物
- 通过二维码定位 + 透视变换,输出标准化正射图像
- 矫正后的图片可直接用于:
核心功能
- 二维码标定 + 图像正射矫正
利用画面中的二维码做坐标标定,自动计算透视变换矩阵,将倾斜照片矫正为垂直视角的正射图。
- 批量图片处理
支持多选图片批量矫正,适合建模素材批量预处理。
- 可视化界面(扁平简约风格)
- 选择图片
- 开始处理
- 清空列表
- 打开文件位置
全程图形化操作,无需命令行。
- 详细信息列表展示
- 文件名
- 文件路径
- 文件大小
- 处理状态(成功 ✅ / 失败 ❌)
- 实时进度条 + 多线程防卡顿
处理图片时界面不会卡死,进度实时可见。
- 自动保存到原文件夹
输出文件命名:原文件名_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 cv2import numpy as npimport osimport threadingimport tkinter as tkfrom tkinter import ttkimport ttkbootstrap as ttkbfrom ttkbootstrap.constants import *from tkinter import filedialog, messageboxfrom pyzbar import pyzbarfrom PIL import Image, ImageTkimport mathimport 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 rectdef 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 ImageDrawimport randomif __name__ == "__main__": # 使用ttkbootstrap的扁平主题 root = ttkb.Window(themename="flatly") app = QRCodeCorrectorApp(root) root.mainloop()
复制代码 运行效果
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |