摘要 :本文基于OpenCV 4.10+与Python 3.10+环境,构建了一套支持中文路径 、中文字体渲染 、实时交互预览 的工业级几何图形绘制工具。通过封装
GeometryDrawer核心类与DrawingGUI界面类,实现了直线、矩形、圆、椭圆、多边形、箭头等图形的绘制,并支持撤销、填充、抗锯齿等高级特性。
关键词:OpenCV;几何绘制;中文路径;PIL字体渲染;Tkinter GUI;交互式预览
1 引言:为什么需要专业的几何绘制工具
在计算机视觉工程实践中,几何图形绘制 (Drawing)不仅是可视化调试的基础手段,更是工业标注、数据增强、结果展示的核心环节。从简单的目标框(Bounding Box)到复杂的多边形分割掩膜(Polygon Mask),从单张图像标注到批量数据生成,开发者需要一个稳定、易用、支持中文环境的绘制工具。
1.1 现有方案的痛点分析
原生OpenCV的局限性:
- 中文路径诅咒 :
cv2.imread("测试图/样本.jpg")在Windows上直接返回None,因OpenCV C++底层使用ANSI编码解析路径 - 字体显示缺陷 :
cv2.putText仅支持ASCII字符,中文显示为"???"或乱码 - 交互能力缺失:纯代码调用无法满足"所见即所得"的交互标注需求
现有GUI工具的不足:
- Labelme等工具功能过重,不适合嵌入自定义算法流程
- 画图板等系统工具无法输出坐标数据,难以与OpenCV pipeline对接
1.2 本文解决方案的技术路线
本文构建的GeometryDrawer工具类,通过以下技术栈解决上述痛点:
| 技术难点 | 解决方案 | 核心技术 |
|---|---|---|
| 中文路径 | 内存缓冲区绕过 | np.fromfile+cv2.imdecode |
| 中文显示 | PIL字体渲染 | ImageFont.truetype+ImageDraw |
| 交互绘制 | Tkinter+OpenCV联动 | Canvas事件绑定+实时预览 |
| 图形管理 | 对象化存储 | dataclass Shape+历史记录栈 |
2 核心技术原理与实现
2.1 中文路径支持的内存缓冲区方案
2.1.1 问题根源:编码陷阱
OpenCV的cv::imread在Windows上调用_wfopen时,默认使用系统代码页(GBK),而Python 3使用UTF-8。这导致包含中文的路径在传递过程中产生编码截断,文件句柄打开失败。
2.1.2 绕过方案:字节流解码
通过NumPy的fromfile接口(支持Unicode路径)读取原始字节,再使用OpenCV的imdecode从内存解码,完全绕过文件路径字符串传递:
python
def imread_chinese(filepath: str) -> Optional[np.ndarray]:
"""支持中文路径的图像读取"""
try:
# 以二进制方式读取文件到numpy数组(支持Unicode路径)
buf = np.fromfile(filepath, dtype=np.uint8)
# 从内存缓冲区解码图像,绕过路径编码问题
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
return img
except Exception as e:
print(f"读取失败: {e}")
return None
技术要点:
np.fromfile使用操作系统原生API,自动处理UTF-8编码cv2.imdecode接收字节数组,不接触文件系统路径- 支持所有OpenCV格式(JPG/PNG/BMP/TIFF),包括Alpha通道
对称的保存方案:
python
def imwrite_chinese(filepath: str, img: np.ndarray) -> bool:
"""支持中文路径的图像保存"""
ext = Path(filepath).suffix.lower()
if ext in ['.jpg', '.jpeg']:
success, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 95])
elif ext == '.png':
success, buf = cv2.imencode('.png', img, [cv2.IMWRITE_PNG_COMPRESSION, 3])
if success:
buf.tofile(filepath) # 关键:使用tofile支持中文路径写入
return True
return False
2.2 中文字体渲染的PIL桥接技术
2.2.1 OpenCV字体机制的限制
cv2.putText基于FreeType或Windows GDI,但OpenCV-Python绑定未暴露中文字体加载接口。即使系统安装了黑体,cv2.FONT_HERSHEY_SIMPLEX也无法渲染"中文"二字。
2.2.2 PIL-OpenCV混合渲染流程
采用PIL绘制+OpenCV格式转换的桥接方案:
- 转换:OpenCV的BGR数组 → PIL的RGB图像
- 渲染 :使用
ImageDraw+TrueType字体绘制文字 - 回传:PIL的RGB → OpenCV的BGR
python
def draw_text(self, text: str, position: Tuple[int, int],
color: Tuple[int, int, int] = (0, 0, 0),
font_size: int = 20):
"""绘制中文文字(PIL桥接方案)"""
# 1. BGR转RGB(PIL使用RGB)
pil_img = Image.fromarray(cv2.cvtColor(self.canvas, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_img)
# 2. 加载系统字体(自动检测黑体/雅黑/文泉驿)
font = ImageFont.truetype(self.font_path, font_size)
# 3. 计算文字尺寸(新PIL使用getbbox)
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 4. 绘制背景(可选)和文字
rgb_color = (color[2], color[1], color[0]) # BGR转RGB
draw.text(position, text, font=font, fill=rgb_color)
# 5. 转换回OpenCV格式
self.canvas = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
字体自动检测策略:
python
def _get_system_font(self) -> str:
"""跨平台中文字体自动检测"""
system = platform.system()
if system == "Windows":
candidates = [
"C:/Windows/Fonts/simhei.ttf", # 黑体(优先)
"C:/Windows/Fonts/msyh.ttc", # 微软雅黑
"C:/Windows/Fonts/simsun.ttc", # 宋体
]
elif system == "Darwin": # macOS
candidates = ["/System/Library/Fonts/PingFang.ttc"]
else: # Linux
candidates = ["/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"]
for font in candidates:
if os.path.exists(font):
return font
return ""
2.3 OpenCV几何绘制的数学基础
2.3.1 抗锯齿(Anti-Aliasing)技术
OpenCV提供两种线型:
cv2.LINE_8:8连通线,速度快但锯齿明显cv2.LINE_AA:抗锯齿线,使用高斯滤波对边缘像素进行加权混合,视觉上更平滑
本文所有绘制操作默认使用LINE_AA,确保斜线无锯齿。
2.3.2 填充与轮廓的区分
通过thickness参数控制:
thickness > 0:轮廓线宽度(像素)thickness == -1:填充模式(cv2.FILLED)
对于多边形,填充使用cv2.fillPoly,轮廓使用cv2.polylines,二者API分离需注意。
2.3.3 坐标系与几何计算
圆的半径计算 :
当用户拖拽绘制圆时,通过欧氏距离计算半径:
python
radius = int(np.linalg.norm(np.array(center) - np.array(edge_point)))
椭圆轴长 :
椭圆axes参数为(长轴半径, 短轴半径),与拖拽框的宽高对应:
python
axes = (abs(end_x - start_x), abs(end_y - start_y))
3 系统架构设计:MVC模式与类封装
3.1 整体架构图
┌─────────────────────────────────────────────────┐
│ DrawingGUI (View) │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ 工具选择栏 │ │ 属性调节区 │ │ 画布区域 │ │
│ │ (RadioButton)│ │ (Scale/Check)│ │ (Canvas) │ │
│ └──────┬──────┘ └──────┬──────┘ └────┬─────┘ │
└─────────┼────────────────┼──────────────┼───────┘
│ │ │
└────────────────┴──────────────┘
│
┌──────────────────────────▼──────────────────────┐
│ GeometryDrawer (Controller+Model) │
│ ┌───────────────────────────────────────────┐ │
│ │ canvas: np.ndarray (H×W×3) │ │
│ │ shapes: List[Shape] (图形历史记录) │ │
│ │ temp_canvas: np.ndarray (预览层) │ │
│ └───────────────────────────────────────────┘ │
│ 接口:draw_line(), draw_circle(), undo()... │
└─────────────────────────────────────────────────┘
3.2 数据模型:Shape类设计
使用@dataclass定义图形数据模型,支持任意图形的序列化存储:
python
@dataclass
class Shape:
shape_type: str # 图形类型标识
points: List[Tuple[int, int]] # 顶点坐标序列
color: Tuple[int, int, int] # BGR色彩
thickness: int # 线宽(-1表示填充)
filled: bool # 是否填充
text: str = "" # 文字内容(可选)
动态属性扩展 :
对于圆、椭圆等需要额外参数的图形,使用Python的动态属性机制:
python
shape = Shape('circle', [center], color, thickness, False)
shape.radius = radius # 运行时添加半径属性
这种设计使得**撤销功能(Undo)**成为可能:通过保存shapes列表,可以重绘所有历史图形。
3.3 核心绘制类:GeometryDrawer
3.3.1 状态管理
- 主画布 (
canvas):已确认的绘制结果 - 预览层 (
temp_canvas):鼠标拖拽时的临时显示,松开鼠标后合并到主画布或丢弃
3.3.2 绘制API设计原则
单一职责 :每个draw_xxx方法完成:
- 调用OpenCV/PIL绘制函数
- 创建Shape对象记录参数
- 追加到历史列表
示例:矩形绘制
python
def draw_rectangle(self, pt1, pt2, color=(0, 255, 0),
thickness=2, filled=False):
# 1. 绘制
thick = -1 if filled else thickness
cv2.rectangle(self.canvas, pt1, pt2, color, thick, cv2.LINE_AA)
# 2. 记录
shape = Shape('rectangle', [pt1, pt2], color, thickness, filled)
self.shapes.append(shape)
return shape
3.3.3 撤销机制(Undo)
重绘策略(而非像素级回退):
- 清空画布为背景色
- 遍历
shapes列表(排除最后一个) - 根据每个Shape的参数重新调用绘制函数
python
def undo(self):
if len(self.shapes) > 0:
self.shapes.pop()
self._redraw_all()
def _redraw_all(self):
self.canvas = np.full((self.height, self.width, 3),
self.bg_color, dtype=np.uint8)
for shape in self.shapes:
self._redraw_shape(shape)
优势:内存占用低(只需存储参数,无需存储每步的像素矩阵),支持无限步撤销(仅受限于列表长度)。
4 交互式GUI实现详解
4.1 Tkinter与OpenCV的桥接
4.1.1 图像显示机制
Tkinter的Canvas不直接支持NumPy数组,需通过PIL Image作为中间格式:
python
def update_canvas(self):
# 1. 获取OpenCV图像(BGR格式)
img = self.drawer.get_canvas()
# 2. 转换为RGB(PIL要求)
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pil_img = Image.fromarray(img_rgb)
# 3. 转换为Tkinter PhotoImage
self.tk_img = ImageTk.PhotoImage(image=pil_img)
# 4. 更新Canvas
self.canvas_widget.create_image(0, 0, anchor=tk.NW, image=self.tk_img)
关键陷阱 :PhotoImage对象必须保持引用(self.tk_img),否则会被Python垃圾回收,导致画布空白。
4.1.2 鼠标事件映射
绑定Tkinter的鼠标事件到绘制逻辑:
| Tkinter事件 | 触发时机 | 处理函数 | 功能 |
|---|---|---|---|
<Button-1> |
左键按下 | on_mouse_down |
记录起点/添加多边形顶点 |
<B1-Motion> |
左键拖拽 | on_mouse_move |
实时预览图形 |
<ButtonRelease-1> |
左键释放 | on_mouse_up |
确认绘制,提交到画布 |
<Double-Button-1> |
双击 | on_double_click |
结束多边形绘制 |
实时预览实现 :
在on_mouse_move中,调用drawer.preview_shape()生成临时画布,不修改主画布,实现"橡皮筋"效果:
python
def on_mouse_move(self, event):
if not self.drawing:
return
end_point = (event.x, event.y)
# 在临时画布上预览,不污染主画布
if self.current_tool == "rectangle":
self.drawer.preview_shape("rectangle",
[self.start_point, end_point],
self.current_color,
self.thickness)
self.update_canvas() # 显示临时画布
4.2 工具状态机设计
通过self.current_tool变量控制当前绘制模式,不同模式对应不同的鼠标处理逻辑:
直线/矩形/圆:两点式(按下→拖拽→释放)
多边形:多点式(多次点击添加顶点,双击闭合):
python
def on_mouse_down(self, event):
if self.current_tool == "polygon":
self.temp_points.append((event.x, event.y))
if len(self.temp_points) > 1:
# 实时显示已连接的边
self.update_canvas()
文字:点击即输入(弹出Entry控件)
4.3 多边形绘制的特殊处理
多边形需要动态边数,采用"点击添加+双击结束"的交互模式:
- 点击 :添加顶点
temp_points.append((x,y)) - 移动:从最后一个顶点绘制虚线到鼠标位置(视觉引导)
- 双击 :闭合多边形,调用
draw_polygon(temp_points, is_closed=True)
临时边绘制 :
在update_canvas中,除显示主画布外,额外使用Tkinter的create_line绘制多边形的临时边(红色虚线),这些边不属于最终图像,仅作为交互提示。
5 功能模块详解与使用技巧
5.1 各图形绘制特性
5.1.1 直线与箭头
直线 :支持抗锯齿(LINE_AA),适合标注边界。
箭头 :使用cv2.arrowedLine,通过tipLength参数控制箭头头部长度(相对于线段长度的比例,默认0.1即10%)。
5.1.2 矩形与椭圆
矩形 :标准轴对齐矩形。如需旋转矩形,需使用cv2.boxPoints+draw_contours,本文工具类中暂未封装,可通过多边形实现。
椭圆 :axes参数为半轴长度(非直径)。旋转角度angle为顺时针角度(OpenCV坐标系Y轴向下)。
5.1.3 文字标注的高级特性
背景色支持 :通过draw.rectangle绘制文字背景框,增强可读性:
python
if bg_color:
padding = 5
draw.rectangle([x-padding, y-padding, x+text_width+padding, y+text_height+padding],
fill=bg_color)
字体大小映射 :将滑动条的"线宽"映射到字体大小(font_size = thickness * 10),保持交互一致性。
5.2 快捷键与效率优化
| 快捷键 | 功能 | 实现方式 |
|---|---|---|
Ctrl+Z |
撤销 | root.bind("<Control-z>", lambda e: self.undo()) |
Ctrl+S |
保存 | 绑定save_image方法 |
Esc(文字模式) |
取消输入 | text_input.bind("<Escape>", ...) |
5.3 工业应用示例
5.3.1 缺陷标注流程
- 加载工业检测图像(支持中文路径
D:/产线数据/批次A/001.jpg) - 使用红色矩形框选缺陷区域
- 使用文字工具标注缺陷类型(如"划痕-长度5mm")
- 保存标注图到
D:/标注结果/批次A/001_标注.jpg
5.3.2 数据增强中的几何绘制
在数据增强pipeline中调用GeometryDrawer:
python
drawer = GeometryDrawer(640, 480)
drawer.load_image("背景图.jpg")
drawer.draw_rectangle((100,100), (200,200), (0,255,0), 2) # 模拟检测框
drawer.draw_text("置信度: 0.95", (100, 80), (0,255,0), 15)
result = drawer.get_canvas()
6 性能优化与扩展建议
6.1 性能瓶颈分析
大图像处理 :当图像尺寸超过2000×2000时,Tkinter的PhotoImage转换可能成为瓶颈。优化方案:
- 缩放预览:在画布上显示缩略图,实际绘制在完整分辨率图像上进行
- 分块更新:仅重绘变化的区域(需实现ROI管理)
撤销性能 :当图形数量超过1000个时,全量重绘会变慢。可采用增量重绘策略:
- 保存每步的像素差异(diff)
- 撤销时仅恢复差异区域
6.2 功能扩展方向
1. 图形编辑功能
- 选中/移动已绘制的图形(需实现碰撞检测与坐标变换)
- 属性修改(选中后修改颜色/线宽)
2. 序列化与协议对接
- 将
shapes列表导出为COCO/LabelMe格式的JSON标注文件 - 支持从JSON加载标注并可视化
3. 高级绘制模式
- 贝塞尔曲线(使用
cv2.approxPolyDP或手动计算曲线点) - 旋转矩形(支持任意角度,非轴对齐)
7 总结
本文构建的OpenCV几何绘制工具,通过内存缓冲区方案 解决了中文路径难题,通过PIL桥接技术 实现了中文字体渲染,通过MVC架构实现了可撤销的交互式绘制。该工具可直接用于工业视觉标注、教学演示、算法验证等场景。
核心技术点回顾:
- 中文路径 :
np.fromfile+cv2.imdecode绕过编码限制 - 中文显示:PIL TrueType字体+RGB/BGR格式转换
- 交互架构:预览层/主画布分离,支持实时拖拽预览
- 撤销机制:基于Shape列表的重绘策略,内存友好
附录A 完整源代码
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenCV几何图形绘制工具类 - 完整GUI版
功能:支持直线、矩形、圆、多边形、椭圆绘制,带中文字体显示
环境:Python 3.10+, OpenCV 4.8+, Pillow, NumPy
"""
import cv2
import numpy as np
import tkinter as tk
from tkinter import ttk, colorchooser, filedialog, messagebox
from PIL import Image, ImageDraw, ImageFont, ImageTk # Added ImageTk import
from pathlib import Path
from dataclasses import dataclass
from typing import List, Tuple, Optional, Union
import copy
import os
import platform
# ==================== 工具函数:中文路径支持 ====================
def imread_chinese(filepath: str) -> Optional[np.ndarray]:
"""支持中文路径的图像读取"""
try:
buf = np.fromfile(filepath, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
return img
except Exception as e:
print(f"读取失败: {e}")
return None
def imwrite_chinese(filepath: str, img: np.ndarray) -> bool:
"""支持中文路径的图像保存"""
try:
ext = Path(filepath).suffix.lower()
if ext in ['.jpg', '.jpeg']:
success, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 95])
elif ext == '.png':
success, buf = cv2.imencode('.png', img, [cv2.IMWRITE_PNG_COMPRESSION, 3])
else:
success, buf = cv2.imencode(ext, img)
if success:
buf.tofile(filepath)
return True
return False
except Exception as e:
print(f"保存失败: {e}")
return False
# ==================== 核心类:几何图形绘制器 ====================
@dataclass
class Shape:
"""图形数据类"""
shape_type: str # 'line', 'rectangle', 'circle', 'polygon', 'ellipse', 'text'
points: List[Tuple[int, int]]
color: Tuple[int, int, int]
thickness: int
filled: bool
text: str = "" # 用于text类型
class GeometryDrawer:
"""
OpenCV几何图形绘制工具类
支持:直线、矩形、圆、多边形、椭圆、中文文字
"""
def __init__(self, width: int = 800, height: int = 600, bg_color: Tuple[int, int, int] = (255, 255, 255)):
self.width = width
self.height = height
self.bg_color = bg_color
self.canvas = np.full((height, width, 3), bg_color, dtype=np.uint8)
self.shapes: List[Shape] = [] # 存储所有图形对象
self.temp_canvas: Optional[np.ndarray] = None # 用于拖拽预览
# 中文字体配置
self.font_path = self._get_system_font()
self.font_size = 20
def _get_system_font(self) -> str:
"""获取系统默认中文字体路径"""
system = platform.system()
if system == "Windows":
# Windows常见中文字体
font_candidates = [
"C:/Windows/Fonts/simhei.ttf", # 黑体
"C:/Windows/Fonts/simsun.ttc", # 宋体
"C:/Windows/Fonts/msyh.ttc", # 微软雅黑
]
elif system == "Darwin": # macOS
font_candidates = [
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
else: # Linux
font_candidates = [
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for font in font_candidates:
if os.path.exists(font):
return font
# 如果没有中文字体,返回空,绘制时将使用默认
return ""
def reset_canvas(self):
"""重置画布"""
self.canvas = np.full((self.height, self.width, 3), self.bg_color, dtype=np.uint8)
self.shapes.clear()
def create_blank(self, width: int, height: int, color: Tuple[int, int, int] = (255, 255, 255)):
"""创建指定尺寸空白画布"""
self.width = width
self.height = height
self.bg_color = color
self.canvas = np.full((height, width, 3), color, dtype=np.uint8)
self.shapes.clear()
def draw_line(self, pt1: Tuple[int, int], pt2: Tuple[int, int],
color: Tuple[int, int, int] = (0, 0, 255),
thickness: int = 2, anti_alias: bool = True) -> Shape:
"""
绘制直线
anti_alias: 是否使用抗锯齿(LINE_AA)
"""
line_type = cv2.LINE_AA if anti_alias else cv2.LINE_8
cv2.line(self.canvas, pt1, pt2, color, thickness, line_type)
shape = Shape('line', [pt1, pt2], color, thickness, False)
self.shapes.append(shape)
return shape
def draw_rectangle(self, pt1: Tuple[int, int], pt2: Tuple[int, int],
color: Tuple[int, int, int] = (0, 255, 0),
thickness: int = 2, filled: bool = False) -> Shape:
"""
绘制矩形
filled: 是否填充
"""
thick = -1 if filled else thickness
cv2.rectangle(self.canvas, pt1, pt2, color, thick, cv2.LINE_AA)
shape = Shape('rectangle', [pt1, pt2], color, thickness, filled)
self.shapes.append(shape)
return shape
def draw_circle(self, center: Tuple[int, int], radius: int,
color: Tuple[int, int, int] = (255, 0, 0),
thickness: int = 2, filled: bool = False) -> Shape:
"""
绘制圆形
"""
thick = -1 if filled else thickness
cv2.circle(self.canvas, center, radius, color, thick, cv2.LINE_AA)
shape = Shape('circle', [center], color, thickness, filled)
shape.radius = radius # 动态添加属性
self.shapes.append(shape)
return shape
def draw_ellipse(self, center: Tuple[int, int], axes: Tuple[int, int],
angle: int = 0, start_angle: int = 0, end_angle: int = 360,
color: Tuple[int, int, int] = (128, 0, 128),
thickness: int = 2, filled: bool = False) -> Shape:
"""
绘制椭圆
axes: (长轴半径, 短轴半径)
angle: 旋转角度(度)
"""
thick = -1 if filled else thickness
cv2.ellipse(self.canvas, center, axes, angle, start_angle, end_angle, color, thick, cv2.LINE_AA)
shape = Shape('ellipse', [center], color, thickness, filled)
shape.axes = axes
shape.angle = angle
self.shapes.append(shape)
return shape
def draw_polygon(self, points: List[Tuple[int, int]],
color: Tuple[int, int, int] = (0, 255, 255),
thickness: int = 2, filled: bool = False, is_closed: bool = True) -> Shape:
"""
绘制多边形
points: 顶点坐标列表 [(x1,y1), (x2,y2), ...]
"""
pts = np.array(points, np.int32).reshape((-1, 1, 2))
thick = -1 if filled else thickness
cv2.polylines(self.canvas, [pts], is_closed, color, thick, cv2.LINE_AA)
shape = Shape('polygon', points, color, thickness, filled)
shape.is_closed = is_closed
self.shapes.append(shape)
return shape
def draw_text(self, text: str, position: Tuple[int, int],
color: Tuple[int, int, int] = (0, 0, 0),
font_size: int = 20,
bg_color: Optional[Tuple[int, int, int]] = None) -> Shape:
"""
绘制中文文字(使用PIL)
position: 文字左上角坐标
bg_color: 文字背景色(None为透明)
"""
if not self.font_path or not os.path.exists(self.font_path):
# 回退到OpenCV默认字体(不支持中文)
cv2.putText(self.canvas, text, position, cv2.FONT_HERSHEY_SIMPLEX,
font_size / 20, color, 2, cv2.LINE_AA)
shape = Shape('text', [position], color, 2, False, text)
self.shapes.append(shape)
return shape
# 使用PIL绘制中文
pil_img = Image.fromarray(cv2.cvtColor(self.canvas, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil_img)
try:
font = ImageFont.truetype(self.font_path, font_size)
except Exception as e: # Catch potential font loading errors
print(f"Error loading font: {e}. Falling back to default.")
font = ImageFont.load_default()
# 计算文字尺寸(PIL 10.0+使用getbbox)
try:
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
except AttributeError: # Fallback for older Pillow versions
text_width, text_height = draw.textsize(text, font=font)
# 绘制背景
if bg_color:
padding = 5
draw.rectangle(
[position[0] - padding, position[1] - padding,
position[0] + text_width + padding, position[1] + text_height + padding],
fill=bg_color
)
# 绘制文字(注意PIL是RGB,需要转换)
rgb_color = (color[2], color[1], color[0]) # BGR转RGB
draw.text(position, text, font=font, fill=rgb_color)
# 转换回OpenCV格式
self.canvas = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
shape = Shape('text', [position], color, font_size, False, text)
shape.text_size = (text_width, text_height)
shape.bg_color = bg_color
self.shapes.append(shape)
return shape
def draw_arrow(self, pt1: Tuple[int, int], pt2: Tuple[int, int],
color: Tuple[int, int, int] = (0, 165, 255),
thickness: int = 2, tip_length: float = 0.1) -> Shape:
"""
绘制箭头
"""
cv2.arrowedLine(self.canvas, pt1, pt2, color, thickness, cv2.LINE_AA, tipLength=tip_length)
shape = Shape('arrow', [pt1, pt2], color, thickness, False)
shape.tip_length = tip_length
self.shapes.append(shape)
return shape
def preview_shape(self, shape_type: str, points: List[Tuple[int, int]],
color: Tuple[int, int, int], thickness: int, **kwargs):
"""
预览图形(在临时画布上绘制,不保存到shapes列表)
用于鼠标拖拽时的实时预览
"""
self.temp_canvas = self.canvas.copy()
if shape_type == 'line':
cv2.line(self.temp_canvas, points[0], points[1], color, thickness, cv2.LINE_AA)
elif shape_type == 'rectangle':
filled = kwargs.get('filled', False)
thick = -1 if filled else thickness
cv2.rectangle(self.temp_canvas, points[0], points[1], color, thick, cv2.LINE_AA)
elif shape_type == 'circle':
radius = int(np.linalg.norm(np.array(points[0]) - np.array(points[1])))
filled = kwargs.get('filled', False)
thick = -1 if filled else thickness
cv2.circle(self.temp_canvas, points[0], radius, color, thick, cv2.LINE_AA)
elif shape_type == 'polygon' and len(points) > 1:
pts = np.array(points, np.int32).reshape((-1, 1, 2))
cv2.polylines(self.temp_canvas, [pts], False, color, thickness, cv2.LINE_AA)
return self.temp_canvas
def get_canvas(self) -> np.ndarray:
"""获取当前画布(如果有预览则返回预览图)"""
if self.temp_canvas is not None:
return self.temp_canvas
return self.canvas
def commit_preview(self):
"""确认预览,将临时画布保存为正式图形"""
if self.temp_canvas is not None:
self.canvas = self.temp_canvas.copy()
self.temp_canvas = None
def cancel_preview(self):
"""取消预览"""
self.temp_canvas = None
def undo(self):
"""撤销上一步(简化版:重绘所有图形除最后一个)"""
if len(self.shapes) > 0:
self.shapes.pop()
self._redraw_all()
def _redraw_all(self):
"""根据shapes列表重绘所有图形(用于撤销后刷新)"""
self.canvas = np.full((self.height, self.width, 3), self.bg_color, dtype=np.uint8)
for shape in self.shapes:
self._redraw_shape(shape)
def _redraw_shape(self, shape: Shape):
"""重绘单个图形"""
if shape.shape_type == 'line':
cv2.line(self.canvas, shape.points[0], shape.points[1], shape.color, shape.thickness, cv2.LINE_AA)
elif shape.shape_type == 'rectangle':
thick = -1 if shape.filled else shape.thickness
cv2.rectangle(self.canvas, shape.points[0], shape.points[1], shape.color, thick, cv2.LINE_AA)
elif shape.shape_type == 'circle':
center = shape.points[0]
radius = getattr(shape, 'radius', 50)
thick = -1 if shape.filled else shape.thickness
cv2.circle(self.canvas, center, radius, shape.color, thick, cv2.LINE_AA)
elif shape.shape_type == 'polygon':
pts = np.array(shape.points, np.int32).reshape((-1, 1, 2))
thick = -1 if shape.filled else shape.thickness
cv2.polylines(self.canvas, [pts], getattr(shape, 'is_closed', True), shape.color, thick, cv2.LINE_AA)
elif shape.shape_type == 'text':
# 文字重绘较复杂,简化处理
pass
def save_image(self, filepath: str) -> bool:
"""保存画布到文件"""
return imwrite_chinese(filepath, self.canvas)
def load_image(self, filepath: str) -> bool:
"""加载图像作为画布背景"""
img = imread_chinese(filepath)
if img is not None:
self.canvas = img
self.height, self.width = img.shape[:2]
self.shapes.clear() # 加载新图时清空图形记录
return True
return False
# ==================== GUI界面类 ====================
class DrawingGUI:
"""OpenCV几何绘制工具的Tkinter GUI"""
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("OpenCV几何图形绘制工具 - 支持中文")
self.root.geometry("1400x900")
# 初始化绘制器
self.drawer = GeometryDrawer(800, 600)
self.current_tool = "line" # 当前工具
self.current_color = (0, 0, 255) # 默认红色(BGR)
self.current_thickness = 2
self.filled = False
# 鼠标交互状态
self.drawing = False
self.start_point = None
self.temp_points = [] # 用于多边形绘制
self.last_mouse_pos = None # Added for tracking mouse position
self.setup_ui()
self.update_canvas()
def setup_ui(self):
"""设置界面"""
# 主框架
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# 左侧控制面板 - 添加滚动条支持
control_panel_frame = tk.Frame(main_frame)
control_panel_frame.pack(side=tk.LEFT, fill=tk.Y, padx=5, pady=5)
self.canvas_scroll = tk.Canvas(control_panel_frame, borderwidth=0)
self.scrollbar_y = ttk.Scrollbar(control_panel_frame, orient="vertical", command=self.canvas_scroll.yview)
self.scrollbar_x = ttk.Scrollbar(control_panel_frame, orient="horizontal", command=self.canvas_scroll.xview)
self.canvas_scroll.configure(yscrollcommand=self.scrollbar_y.set, xscrollcommand=self.scrollbar_x.set)
self.scrollbar_y.pack(side="right", fill="y")
self.scrollbar_x.pack(side="bottom", fill="x")
self.canvas_scroll.pack(side="left", fill="both", expand=True)
# 将内容框架放置在Canvas上
self.content_frame = ttk.Frame(self.canvas_scroll)
self.canvas_scroll.create_window((0, 0), window=self.content_frame, anchor="nw")
# 绑定Canvas滚动事件
self.content_frame.bind("<Configure>", lambda e: self.canvas_scroll.configure(scrollregion=self.canvas_scroll.bbox("all")))
self.canvas_scroll.bind('<Configure>', self._on_canvas_configure) # Bind configure event
# 工具箱框架 (现在在content_frame内)
tool_frame = ttk.LabelFrame(self.content_frame, text="绘制工具", padding="5")
tool_frame.pack(fill=tk.X, pady=5, padx=5)
tools = [
("直线", "line"),
("矩形", "rectangle"),
("圆形", "circle"),
("椭圆", "ellipse"),
("多边形", "polygon"),
("箭头", "arrow"),
("文字", "text"),
]
self.tool_var = tk.StringVar(value="line")
for text, value in tools:
ttk.Radiobutton(tool_frame, text=text, variable=self.tool_var,
value=value, command=self.on_tool_change).pack(anchor=tk.W, pady=2)
# 属性设置
prop_frame = ttk.LabelFrame(self.content_frame, text="属性设置", padding="5")
prop_frame.pack(fill=tk.X, pady=5, padx=5)
# 颜色选择
ttk.Button(prop_frame, text="选择颜色", command=self.choose_color).pack(fill=tk.X, pady=2)
self.color_preview = tk.Label(prop_frame, bg="#0000ff", width=10, height=1)
self.color_preview.pack(pady=2)
# 线宽
ttk.Label(prop_frame, text="线宽:").pack(anchor=tk.W)
self.thickness_scale = ttk.Scale(prop_frame, from_=1, to=20, orient=tk.HORIZONTAL)
self.thickness_scale.set(2)
self.thickness_scale.pack(fill=tk.X)
# 填充选项
self.filled_var = tk.BooleanVar(value=False)
ttk.Checkbutton(prop_frame, text="填充图形", variable=self.filled_var).pack(anchor=tk.W)
# 画布尺寸
size_frame = ttk.LabelFrame(self.content_frame, text="画布尺寸", padding="5")
size_frame.pack(fill=tk.X, pady=5, padx=5)
ttk.Label(size_frame, text="宽:").pack(anchor=tk.W)
self.width_entry = ttk.Entry(size_frame)
self.width_entry.insert(0, "800")
self.width_entry.pack(fill=tk.X)
ttk.Label(size_frame, text="高:").pack(anchor=tk.W)
self.height_entry = ttk.Entry(size_frame)
self.height_entry.insert(0, "600")
self.height_entry.pack(fill=tk.X)
ttk.Button(size_frame, text="新建画布", command=self.new_canvas).pack(fill=tk.X, pady=5)
# 文件操作
file_frame = ttk.LabelFrame(self.content_frame, text="文件操作", padding="5")
file_frame.pack(fill=tk.X, pady=5, padx=5)
ttk.Button(file_frame, text="打开图像", command=self.open_image).pack(fill=tk.X, pady=2)
ttk.Button(file_frame, text="保存图像", command=self.save_image).pack(fill=tk.X, pady=2)
ttk.Button(file_frame, text="撤销 (Ctrl+Z)", command=self.undo).pack(fill=tk.X, pady=2)
ttk.Button(file_frame, text="清空画布", command=self.clear_canvas).pack(fill=tk.X, pady=2)
# 提示信息
hint_frame = ttk.LabelFrame(self.content_frame, text="操作提示", padding="5")
hint_frame.pack(fill=tk.X, pady=5, padx=5)
self.hint_label = ttk.Label(hint_frame, text="左键拖拽绘制图形\n文字工具点击即输入", wraplength=200)
self.hint_label.pack()
# Right side canvas area
canvas_frame = ttk.LabelFrame(main_frame, text="绘制区域", padding="10")
canvas_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
# OpenCV图像显示使用Tkinter Canvas
self.canvas_widget = tk.Canvas(canvas_frame, width=800, height=600, bg="gray")
self.canvas_widget.pack(fill=tk.BOTH, expand=True)
# 绑定鼠标事件
self.canvas_widget.bind("<Button-1>", self.on_mouse_down)
self.canvas_widget.bind("<B1-Motion>", self.on_mouse_move)
self.canvas_widget.bind("<ButtonRelease-1>", self.on_mouse_up)
self.canvas_widget.bind("<Double-Button-1>", self.on_double_click) # 多边形结束
# 键盘事件
self.root.bind("<Control-z>", lambda e: self.undo())
self.root.bind("<Control-s>", lambda e: self.save_image())
# 文字输入框(默认隐藏)
self.text_input = tk.Entry(self.root)
self.text_input.bind("<Return>", self.on_text_confirm)
self.text_input.bind("<Escape>", self.on_text_cancel)
def _on_canvas_configure(self, event):
"""Callback to update scroll region when canvas widget resizes."""
self.canvas_scroll.configure(scrollregion=self.canvas_scroll.bbox("all"))
def on_tool_change(self):
"""工具切换"""
self.current_tool = self.tool_var.get()
hints = {
"line": "拖拽绘制直线",
"rectangle": "拖拽绘制矩形",
"circle": "拖拽绘制圆形(圆心到边缘)",
"ellipse": "拖拽绘制椭圆",
"polygon": "点击添加顶点,双击结束",
"arrow": "拖拽绘制箭头",
"text": "点击位置输入文字"
}
self.hint_label.config(text=hints.get(self.current_tool, ""))
self.temp_points = [] # 清空临时点
self.drawing = False # Reset drawing state on tool change
self.drawer.cancel_preview() # Cancel any ongoing preview
def choose_color(self):
"""选择颜色"""
color = colorchooser.askcolor(title="选择颜色")[0]
if color:
# 转换为BGR(OpenCV格式)
self.current_color = (int(color[2]), int(color[1]), int(color[0]))
hex_color = '#%02x%02x%02x' % (int(color[0]), int(color[1]), int(color[2]))
self.color_preview.config(bg=hex_color)
def update_canvas(self):
"""刷新画布显示"""
# 获取当前画布(可能是预览状态)
img = self.drawer.get_canvas()
# 转换为PIL Image用于Tkinter显示
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pil_img = Image.fromarray(img_rgb)
# Save reference to the image to prevent garbage collection
self.tk_img = ImageTk.PhotoImage(image=pil_img)
# Update Canvas
self.canvas_widget.delete("all")
self.canvas_widget.create_image(0, 0, anchor=tk.NW, image=self.tk_img)
# Display temporary lines for polygon drawing
if self.current_tool == "polygon" and len(self.temp_points) > 0:
for i in range(len(self.temp_points) - 1):
x1, y1 = self.temp_points[i]
x2, y2 = self.temp_points[i + 1]
self.canvas_widget.create_line(x1, y1, x2, y2, fill="red", width=2)
# Line from the last point to the current mouse position (if dragging)
if self.drawing and self.last_mouse_pos:
x, y = self.temp_points[-1]
self.canvas_widget.create_line(x, y, self.last_mouse_pos[0], self.last_mouse_pos[1],
fill="red", width=2, dash=(4, 4))
def on_mouse_down(self, event):
"""鼠标按下"""
if self.current_tool == "text":
self.show_text_input(event.x, event.y)
return
self.drawing = True
self.start_point = (event.x, event.y)
self.last_mouse_pos = (event.x, event.y) # Initialize last_mouse_pos
if self.current_tool == "polygon":
self.temp_points.append((event.x, event.y))
self.update_canvas()
def on_mouse_move(self, event):
"""鼠标移动(拖拽)"""
self.last_mouse_pos = (event.x, event.y) # Update last_mouse_pos
if not self.drawing:
return
if self.current_tool == "polygon":
self.update_canvas() # Redraw to show the temporary line to cursor
return
end_point = (event.x, event.y)
# Real-time preview
if self.current_tool == "line":
self.drawer.preview_shape("line", [self.start_point, end_point],
self.current_color, self.current_thickness)
elif self.current_tool == "rectangle":
self.drawer.preview_shape("rectangle", [self.start_point, end_point],
self.current_color, self.current_thickness,
filled=self.filled_var.get())
elif self.current_tool == "circle":
self.drawer.preview_shape("circle", [self.start_point, end_point],
self.current_color, self.current_thickness,
filled=self.filled_var.get())
self.update_canvas()
def on_mouse_up(self, event):
"""鼠标释放"""
if not self.drawing or self.current_tool == "polygon":
return
self.drawing = False
end_point = (event.x, event.y)
thickness = int(self.thickness_scale.get())
filled = self.filled_var.get()
# Final drawing
if self.current_tool == "line":
self.drawer.draw_line(self.start_point, end_point, self.current_color, thickness)
elif self.current_tool == "rectangle":
self.drawer.draw_rectangle(self.start_point, end_point, self.current_color, thickness, filled)
elif self.current_tool == "circle":
radius = int(np.linalg.norm(np.array(self.start_point) - np.array(end_point)))
if radius > 0:
self.drawer.draw_circle(self.start_point, radius, self.current_color, thickness, filled)
elif self.current_tool == "ellipse":
axes = (abs(end_point[0] - self.start_point[0]), abs(end_point[1] - self.start_point[1]))
if axes[0] > 0 and axes[1] > 0:
self.drawer.draw_ellipse(self.start_point, axes, 0, 0, 360,
self.current_color, thickness, filled)
elif self.current_tool == "arrow":
self.drawer.draw_arrow(self.start_point, end_point, self.current_color, thickness)
self.drawer.commit_preview()
self.update_canvas()
def on_double_click(self, event):
"""双击结束多边形"""
if self.current_tool == "polygon" and len(self.temp_points) > 2:
self.drawer.draw_polygon(self.temp_points, self.current_color,
int(self.thickness_scale.get()),
self.filled_var.get(), is_closed=True)
self.temp_points = []
self.drawing = False
self.update_canvas()
def show_text_input(self, x, y):
"""显示文字输入框"""
self.text_input.place(x=x, y=y)
self.text_input.focus()
self.text_pos = (x, y)
def on_text_confirm(self, event):
"""确认文字输入"""
text = self.text_input.get()
if text:
self.drawer.draw_text(text, self.text_pos, self.current_color,
int(self.thickness_scale.get()) * 10)
self.text_input.delete(0, tk.END)
self.text_input.place_forget()
self.update_canvas()
def on_text_cancel(self, event):
"""取消文字输入"""
self.text_input.delete(0, tk.END)
self.text_input.place_forget()
def undo(self):
"""撤销"""
self.drawer.undo()
self.update_canvas()
def clear_canvas(self):
"""清空"""
self.drawer.reset_canvas()
self.update_canvas()
def new_canvas(self):
"""新建画布"""
try:
w = int(self.width_entry.get())
h = int(self.height_entry.get())
self.drawer.create_blank(w, h)
self.canvas_widget.config(width=w, height=h)
# Update scroll region after canvas size change
self.root.update_idletasks() # Ensure geometry is updated
self.canvas_scroll.configure(scrollregion=self.canvas_scroll.bbox("all"))
self.update_canvas()
except ValueError:
messagebox.showerror("错误", "请输入有效的数字")
def open_image(self):
"""打开图像"""
filepath = filedialog.askopenfilename(
title="选择图像(支持中文路径)",
filetypes=[("图像文件", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")]
)
if filepath:
if self.drawer.load_image(filepath):
self.canvas_widget.config(width=self.drawer.width, height=self.drawer.height)
# Update scroll region after loading image
self.root.update_idletasks() # Ensure geometry is updated
self.canvas_scroll.configure(scrollregion=self.canvas_scroll.bbox("all"))
self.update_canvas()
messagebox.showinfo("成功", "图像已加载")
else:
messagebox.showerror("错误", "无法加载图像")
def save_image(self):
"""保存图像"""
filepath = filedialog.asksaveasfilename(
title="保存图像(支持中文路径)",
defaultextension=".png",
filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg"), ("所有文件", "*.*")]
)
if filepath:
if self.drawer.save_image(filepath):
messagebox.showinfo("成功", f"图像已保存至:\n{filepath}")
else:
messagebox.showerror("错误", "保存失败")
# ==================== 主程序 ====================
if __name__ == "__main__":
root = tk.Tk()
app = DrawingGUI(root)
root.mainloop()
