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()
相关推荐
花间相见2 小时前
【JAVA基础14】—— 二维数组详解:从基础到实战应用
java·python·算法
Chockong2 小时前
00_最小神经网络训练流程
人工智能·深度学习·神经网络
wjs20242 小时前
jQuery Mobile 表单滑动条
开发语言
zzb15802 小时前
Claude Agent SDK 深度剖析:依赖、权衡与架构选择
人工智能·python·ai
2401_864959282 小时前
分布式日志系统实现
开发语言·c++·算法
youyoulg2 小时前
无监督学习—聚类
人工智能·机器学习·支持向量机
linhaijiao2 小时前
C++与人工智能框架
开发语言·c++·算法
前端付豪2 小时前
实现代码块复制和会话搜索
前端·人工智能·后端
阿聪谈架构2 小时前
第06章:AI RAG 检索增强生成 — 从零到生产(上)
人工智能·后端
会算数的⑨2 小时前
Spring AI Alibaba 学习(四):ToolCalling —— 从LLM到Agent的华丽蜕变
java·开发语言·人工智能·后端·学习·saa·ai agent