OpenCV几何图形绘制工具全栈开发:从中文路径支持到交互式GUI的完整实战(附源码)

摘要 :本文基于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格式转换的桥接方案:

  1. 转换:OpenCV的BGR数组 → PIL的RGB图像
  2. 渲染 :使用ImageDraw+TrueType字体绘制文字
  3. 回传: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方法完成:

  1. 调用OpenCV/PIL绘制函数
  2. 创建Shape对象记录参数
  3. 追加到历史列表

示例:矩形绘制

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)

重绘策略(而非像素级回退):

  1. 清空画布为背景色
  2. 遍历shapes列表(排除最后一个)
  3. 根据每个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 多边形绘制的特殊处理

多边形需要动态边数,采用"点击添加+双击结束"的交互模式:

  1. 点击 :添加顶点temp_points.append((x,y))
  2. 移动:从最后一个顶点绘制虚线到鼠标位置(视觉引导)
  3. 双击 :闭合多边形,调用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 缺陷标注流程

  1. 加载工业检测图像(支持中文路径D:/产线数据/批次A/001.jpg
  2. 使用红色矩形框选缺陷区域
  3. 使用文字工具标注缺陷类型(如"划痕-长度5mm")
  4. 保存标注图到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架构实现了可撤销的交互式绘制。该工具可直接用于工业视觉标注、教学演示、算法验证等场景。

核心技术点回顾

  1. 中文路径np.fromfile+cv2.imdecode绕过编码限制
  2. 中文显示:PIL TrueType字体+RGB/BGR格式转换
  3. 交互架构:预览层/主画布分离,支持实时拖拽预览
  4. 撤销机制:基于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()
相关推荐
AIGC小尼2 小时前
Windows 本地 AI 漫剧全自动生产线部署完整教程(零基础、全指令、带源码、模型配置、排错方案)
人工智能·windows·ai漫剧
合米AI SOP系统2 小时前
传统产线如何快速上马落地 AI 防错?合米科技 AI SOP 7天即可上线。
大数据·人工智能·科技
思录Echo2 小时前
什么决定具身智能的最终走向?多技术路线与落地现实辨析
大数据·人工智能
Despacito10062 小时前
Java后端性能探查工具速查表
java·开发语言
ShallWeL2 小时前
Orin 上多模型常驻与显存预算
人工智能·嵌入式硬件·nvidia·orin
xiaohaiAIgeo2 小时前
【2026年】AI监控加行为分析守护实验室安全
大数据·人工智能·科普知识
IT_陈寒2 小时前
Python的多线程就是个假把式,我算是体验到了
前端·人工智能·后端
晊晌_h3 小时前
嵌入式从0到精通——Linux C|TCP 并发服务器实现方案详解
服务器·开发语言·tcp/ip
集和诚JHCTECH3 小时前
案例分享 | BRAV-7721助力印刷电路板(PCB)智能缺陷复判系统
人工智能·嵌入式硬件·边缘计算