基于二维码的图像正射矫正工具 —— 用于三维建模辅助
拍照辅助三维建模 → 角度不垂直 → 用二维码做标定 → 矫正成正射图 → 用于描边、测量、辅助建模。
基于二维码的图像正射矫正工具——用于三维建模辅助
在使用手机或相机拍摄物体进行三维建模辅助时,经常会遇到拍摄角度无法完全垂直的问题,导致照片存在透视畸变,无法直接用于图像描边、尺寸测量、正射底图等后续建模工作。
为了解决这个问题,我基于开源二维码矫正逻辑,开发了这款二维码辅助图像正射矫正工具,可以快速将倾斜拍摄的图片矫正为无透视畸变的正射图,大幅提升建模前期素材质量。
工具用途与背景
- 拍摄物体用于三维建模、手绘描边、尺寸标注、平面重建
- 手机/相机难以做到绝对垂直拍摄,图像存在透视变形
- 在拍摄场景中放入二维码作为标定参照物
- 通过二维码定位 + 透视变换,输出标准化正射图像
- 矫正后的图片可直接用于:
- 正射底图
- 轮廓描边
- 尺寸测量
- 建模参考
核心功能
-
二维码标定 + 图像正射矫正
利用画面中的二维码做坐标标定,自动计算透视变换矩阵,将倾斜照片矫正为垂直视角的正射图。 -
批量图片处理
支持多选图片批量矫正,适合建模素材批量预处理。 -
可视化界面(扁平简约风格)
- 选择图片
- 开始处理
- 清空列表
- 打开文件位置
全程图形化操作,无需命令行。
-
详细信息列表展示
- 文件名
- 文件路径
- 文件大小
- 处理状态(成功 ✅ / 失败 ❌)
-
实时进度条 + 多线程防卡顿
处理图片时界面不会卡死,进度实时可见。 -
自动保存到原文件夹
输出文件命名:原文件名_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()
运行效果



浙公网安备 33010602011771号