OpenCV Java 教程:从零开始 – wiki基地


OpenCV Java 教程:从零开始,迈入计算机视觉世界

前言

计算机视觉(Computer Vision)是人工智能领域的一个热门分支,旨在赋予计算机“看”的能力,理解和处理图像及视频信息。它在人脸识别、自动驾驶、医学影像分析、工业自动化等众多领域有着广泛的应用。而 OpenCV(Open Source Computer Vision Library)是目前最流行的计算机视觉库之一,它提供了丰富的图像处理和分析功能。

OpenCV 主要使用 C++ 编写,但提供了 Python、Java、MATLAB 等多种语言的接口。对于 Java 开发者来说,能够方便地在熟悉的开发环境中利用强大的 OpenCV 功能,无疑是一个巨大的优势。本教程将带你从零开始,一步步学习如何在 Java 项目中使用 OpenCV,打开计算机视觉的大门。

无论你是一名 Java 后端工程师、Android 开发者,还是仅仅对计算机视觉充满好奇,本教程都将为你提供一个坚实的基础。我们将从环境搭建开始,逐步深入到图像的基本操作、常用算法的应用,并辅以代码示例,确保你能够理解并实践所学内容。

本教程的目标读者: 具备一定 Java 编程基础,但对 OpenCV 或计算机视觉完全陌生的开发者。

我们将涵盖的内容:

  1. OpenCV Java 环境搭建
  2. OpenCV 的核心数据结构:Mat
  3. 图像的读取、显示与保存
  4. 图像基本操作:访问像素、颜色空间转换、尺寸调整、裁剪
  5. 常用的图像处理算法:阈值分割、滤波、边缘检测
  6. 简单的应用示例
  7. 内存管理与最佳实践

让我们开始这段激动人心的计算机视觉之旅吧!

第一章:环境搭建 – 让 OpenCV 在你的 Java 项目中跑起来

在开始编写任何 OpenCV 代码之前,我们需要先将 OpenCV 库引入到 Java 项目中。这通常包括下载 OpenCV 库、配置环境变量(可选,但推荐),以及在你的 Java 项目中添加 OpenCV 的依赖或 JAR 文件。

1.1 下载 OpenCV

首先,你需要从 OpenCV 官方网站下载适用于你的操作系统的安装包。访问 https://opencv.org/releases/,找到最新的稳定版本(通常是最高的非 “pre” 版本),选择适合你系统的版本下载。

  • Windows: 下载 opencv-x.y.z-vcNN.exeopencv-x.y.z-vcNN.zip (其中 x.y.z 是版本号,vcNN 是 Visual C++ 版本,通常下载最新的即可)。运行 .exe 文件或解压 .zip 文件到你想要安装的目录(例如 C:\opencv)。
  • macOS: 下载 .dmg 文件。打开 .dmg 文件并将 OpenCV 文件夹拖到 Applications 目录或你选择的其他位置。
  • Linux: 通常通过包管理器安装(如 sudo apt-get install libopencv-dev)或从源码编译。对于 Java 教程,直接下载预编译版本可能更方便,下载 Linux 版本 .zip 文件并解压。

解压后,你会得到一个包含多个文件夹的目录,其中 build 文件夹包含了编译好的库文件,sources 文件夹包含了源码和示例。我们需要用到 build/java 文件夹下的 JAR 文件和 build/native/<平台>/ 文件夹下的本地库文件。

1.2 配置环境变量 (推荐)

虽然不是必须,但将 OpenCV 的本地库路径添加到系统 PATH 环境变量中,可以使得在运行 Java 程序时更容易找到本地库文件。

  • Windows:
    • 右键点击“此电脑” -> “属性” -> “高级系统设置” -> “环境变量”。
    • 在“系统变量”中找到 Path 变量,选中并点击“编辑”。
    • 点击“新建”,然后添加你的 OpenCV 安装目录下的 build\x64\vcNN\binbuild\x86\vcNN\bin 路径(根据你的系统是 64 位还是 32 位,以及下载的 VC++ 版本选择)。例如:C:\opencv\build\x64\vc16\bin
    • 一路点击“确定”保存设置。可能需要重启电脑或注销用户才能使环境变量生效。
  • macOS/Linux:
    • 打开终端。
    • 编辑你的 shell 配置文件(例如 ~/.bashrc, ~/.zshrc, ~/.profile)。
    • 添加一行类似如下的配置,将 /path/to/your/opencv/build/native/ 替换为你实际的本地库路径:
      bash
      export PATH=$PATH:/path/to/your/opencv/build/native/
      # 或者更具体的路径,例如:
      # export PATH=$PATH:/Users/your_user/opencv/build/native/osx/
      # 或者对于 Linux:
      # export PATH=$PATH:/home/your_user/opencv/build/native/lib/
    • 保存文件并退出编辑器。
    • 在终端执行 source ~/.bashrc (或对应的文件) 使配置立即生效。

1.3 在 Java 项目中引入 OpenCV

有两种主要的方法在 Java 项目中使用 OpenCV:使用构建工具(如 Maven 或 Gradle)或手动添加 JAR 文件。推荐使用构建工具,因为它更方便管理依赖。

方法一:使用 Maven (推荐)

如果你使用 Maven 管理项目,可以在 pom.xml 文件中添加 OpenCV 的依赖。注意,OpenCV 官方 releases 提供的 Maven 仓库可能不是最新的或最稳定的,许多开发者选择使用第三方的 Maven 仓库或手动安装 JAR 到本地 Maven 仓库。这里我们介绍手动安装 JAR 到本地仓库的方法,这通常是最可靠的方式。

  1. 找到你下载的 OpenCV 目录下的 build/java/opencv-x.y.z.jarbuild/native/<平台>/opencv_java<版本号>.<dll/so/dylib> 文件。
  2. 打开终端或命令提示符,使用 Maven 命令将 JAR 安装到本地仓库:
    bash
    mvn install:install-file \
    -Dfile=/path/to/your/opencv/build/java/opencv-4.x.y.jar \
    -DgroupId=org.opencv \
    -DartifactId=opencv \
    -Dversion=4.x.y \
    -Dpackaging=jar

    /path/to/your/opencv/build/java/opencv-4.x.y.jar 替换为你实际的 JAR 文件路径,-Dversion 和文件路径中的版本号保持一致。
  3. 在你的 Maven 项目的 pom.xml 文件中添加如下依赖:
    xml
    <dependency>
    <groupId>org.opencv</groupId>
    <artifactId>opencv</artifactId>
    <version>4.x.y</version> <!-- 与你安装到本地仓库的版本号一致 -->
    </dependency>
  4. Maven 会自动下载依赖。但是,OpenCV Java 依赖于本地库文件,Maven 不会自动管理本地库。你需要在程序启动时手动加载本地库:
    “`java
    import org.opencv.core.Core;

    public class Main {
    static {
    // 加载 OpenCV 本地库
    // 如果你配置了环境变量,OpenCV 会在 PATH 中查找库文件
    // 否则,你需要确保库文件(如 opencv_java4xx.dll/.so/.dylib)
    // 在 Java 运行时库路径(java.library.path)中可找到
    System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
    System.out.println(“OpenCV Version: ” + Core.VERSION);
    }

    public static void main(String[] args) {
        System.out.println("Hello, OpenCV!");
        // TODO: Your OpenCV code here
    }
    

    }
    ``
    确保你的本地库文件(例如
    opencv_java455.dlllibopencv_java455.solibopencv_java455.dylib)在你程序的运行环境中可以被找到。最简单的方法是将对应的库文件拷贝到你的项目运行目录(通常是 target 目录或与你的.jar` 文件同级)或者将包含库文件的目录添加到系统 PATH 环境变量。

方法二:手动添加 JAR 文件

如果你不使用 Maven 或 Gradle,可以手动将 JAR 文件添加到项目的构建路径(Classpath)。

  1. 找到你下载的 OpenCV 目录下的 build/java/opencv-x.y.z.jar 文件。
  2. 在你喜欢的 IDE 中(如 Eclipse, IntelliJ IDEA):
    • Eclipse: 右键项目 -> Properties -> Java Build Path -> Libraries -> Add External JARs… 选择 opencv-x.y.z.jar 文件。
    • IntelliJ IDEA: File -> Project Structure -> Modules -> Dependencies -> 点击 ‘+’ -> JARs or Directories… 选择 opencv-x.y.z.jar 文件。
  3. 同样,你需要在程序启动时手动加载本地库:
    “`java
    import org.opencv.core.Core;

    public class Main {
    static {
    // 加载 OpenCV 本地库
    // 确保本地库文件在你程序的运行环境中可以被找到
    System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
    System.out.println(“OpenCV Version: ” + Core.VERSION);
    }

    public static void main(String[] args) {
        System.out.println("Hello, OpenCV!");
        // TODO: Your OpenCV code here
    }
    

    }
    ``
    你需要确保对应的本地库文件(如
    opencv_java4xx.dlllibopencv_java4xx.solibopencv_java4xx.dylib)在运行时能够被System.loadLibrary()` 找到。将它们复制到项目运行目录或添加到 PATH 是常见做法。

1.4 验证安装

无论你使用哪种方法,创建一个简单的 Java 类,包含上面加载本地库的代码,并打印 OpenCV 版本。如果程序能够成功运行并打印出版本号,说明 OpenCV Java 环境已经搭建成功。

“`java
import org.opencv.core.Core;

public class OpenCVEnvCheck {
static {
System.out.println(“尝试加载 OpenCV 本地库…”);
try {
// 假设本地库文件在系统路径或项目运行路径下
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
System.out.println(“OpenCV 本地库加载成功!”);
} catch (UnsatisfiedLinkError e) {
System.err.println(“OpenCV 本地库加载失败!”);
System.err.println(“请检查以下事项:”);
System.err.println(“1. 确保已下载并解压 OpenCV.”);
System.err.println(“2. 确认系统架构 (32/64 位) 与下载的 OpenCV 版本匹配.”);
System.err.println(“3. 确认本地库文件 (如 opencv_java4xx.dll/.so/.dylib) 存在于 OpenCV 安装目录的 build/native/<平台> 文件夹下.”);
System.err.println(“4. 确认本地库文件所在的目录已添加到系统 PATH 环境变量 或 已复制到项目运行目录.”);
System.err.println(“5. 确认你的 IDE 或构建工具已正确配置 java.library.path (如果未使用系统 PATH).”);
System.err.println(“错误详情: ” + e.getMessage());
System.exit(1); // 退出程序
}
}

public static void main(String[] args) {
    System.out.println("Hello, OpenCV " + Core.VERSION + "!");
    // 现在你可以开始写 OpenCV 代码了!
}

}
“`
运行这个程序,看到类似 “OpenCV 本地库加载成功!” 和 “Hello, OpenCV 4.x.y!” 的输出,就代表环境搭建成功了。

第二章:OpenCV 的核心数据结构 – Mat

在 OpenCV 中,图像、矩阵、向量等所有数据都通过一个核心类来表示:Mat。理解 Mat 是使用 OpenCV 的关键。

Mat 是 Matrix(矩阵)的缩写,它不仅仅用于存储图像像素,还可以存储特征点坐标、变换矩阵、直方图数据等等。简单来说,Mat 是一个多维密集数组。

2.1 Mat 的基本属性

一个 Mat 对象包含以下重要属性:

  • Dimensions (维度): 图像通常是二维的 (高度 x 宽度),但 Mat 可以是 N 维的。
  • Size (尺寸): rows() 表示行数(高度),cols() 表示列数(宽度)。
  • Channels (通道): 每个像素包含的数据通道数。例如,灰度图像有 1 个通道,彩色图像 (BGR 或 RGB) 有 3 个通道,包含 Alpha 通道的图像有 4 个通道。
  • Depth (深度/数据类型): 每个通道中像素值的类型。OpenCV 定义了一系列常量来表示数据类型,格式通常是 CV_<位深度><U或S或F>C<通道数>
    • 位深度: 8 (8-bit), 16 (16-bit), 32 (32-bit), 64 (64-bit)
    • U: Unsigned (无符号整型)
    • S: Signed (有符号整型)
    • F: Float (浮点型)
    • C<通道数>: 通道数 (C1, C2, C3, C4…)
    • 例如:CV_8UC1 表示 8 位无符号单通道(灰度图像常用),CV_8UC3 表示 8 位无符号三通道(彩色图像常用),CV_32FC1 表示 32 位浮点型单通道。
  • Type (类型): 结合了深度和通道数,用一个整数表示。type() 方法返回这个整数值。CvType 类提供了解析和创建类型的功能。例如,CvType.CV_8UC3 对应 CV_8UC3 类型。
  • Data (数据): 实际存储像素值的内存块。

2.2 创建 Mat 对象

你可以通过多种方式创建 Mat 对象:

  • 创建空 Mat
    java
    Mat emptyMat = new Mat(); // 创建一个空的 Mat
  • 创建指定尺寸和类型的 Mat
    “`java
    import org.opencv.core.CvType;
    import org.opencv.core.Mat;
    import org.opencv.core.Scalar;

    // 创建一个 100×200 像素,类型为 CV_8UC3 (8位无符号,3通道) 的黑色图像
    Mat blackImage = new Mat(100, 200, CvType.CV_8UC3, new Scalar(0, 0, 0)); // 高度, 宽度, 类型, 初始填充值
    // 创建一个 64×64 像素,类型为 CV_8UC1 (8位无符号,单通道) 的白色图像
    Mat whiteImage = new Mat(64, 64, CvType.CV_8UC1, new Scalar(255)); // 对于单通道,Scalar只需要一个值
    ``Scalar用于指定初始填充值。对于多通道图像,Scalar` 的值对应每个通道的像素值(OpenCV 默认是 BGR 顺序)。

  • 创建与现有 Mat 具有相同尺寸和类型的 Mat
    java
    Mat otherMat = new Mat(blackImage.size(), blackImage.type());

  • 从数组创建 Mat (较少用于图像,多用于小的变换矩阵等):
    java
    double[] data = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0};
    Mat matrix = new Mat(2, 3, CvType.CV_64FC1); // 2x3 矩阵,64位浮点单通道
    matrix.put(0, 0, data); // 将数据填充到 Mat 中

2.3 释放 Mat 资源 (release())

Mat 对象在内部管理着 C++ 层的内存资源。当一个 Mat 对象不再需要时,你应该显式地调用 release() 方法来释放其内部的 C++ 内存。这对于避免内存泄漏非常重要,特别是在循环处理大量图像时。

“`java
Mat image = new Mat(100, 100, CvType.CV_8UC3, new Scalar(128, 128, 128));
// … 对 image 进行操作 …

// 当不再需要 image 时
image.release();
“`

重要提示: Java 的垃圾回收机制只回收 Java 对象本身占用的内存,不会自动释放 Mat 内部指向的 C++ 堆内存。因此,务必Mat 不再使用时调用 release()

如果你在一个函数中创建并返回 Mat,通常由调用者负责释放。如果一个 Mat 是另一个 Mat 的子矩阵 (submat()),它们共享同一块数据,释放父 Mat 或子 Mat 都可能影响到共享数据,需要小心管理。但对于通过 new Mat() 创建的对象,明确调用 release() 是最佳实践。

第三章:图像的读取、显示与保存

这是计算机视觉应用中最基础的操作:如何加载一张图片,如何在屏幕上看到它,以及如何将处理结果保存到文件。

3.1 读取图像 (Imgcodecs.imread)

Imgcodecs 类提供了读写图像文件的功能。imread() 方法用于从指定路径读取图像文件。

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;

// 读取一个图像文件
String imagePath = “path/to/your/image.jpg”; // 替换为你的图片路径
Mat image = Imgcodecs.imread(imagePath);

// 检查图像是否成功读取
if (image.empty()) {
System.out.println(“错误: 图像文件无法读取或文件不存在!”);
} else {
System.out.println(“图像读取成功! 尺寸: ” + image.cols() + “x” + image.rows() + “, 通道: ” + image.channels());
// … 对图像进行操作 …

// 释放资源
image.release();

}
``imread()` 支持多种图像格式,如 JPG, PNG, BMP, TIFF 等。默认情况下,它会以彩色模式读取图像(即使是灰度图片也会转换为 BGR 格式)。你可以传递第二个参数来指定读取模式:

  • Imgcodecs.IMREAD_COLOR: 以彩色模式读取 (默认)。任何透明度信息都会被忽略。
  • Imgcodecs.IMREAD_GRAYSCALE: 以灰度模式读取。
  • Imgcodecs.IMREAD_UNCHANGED: 读取图像,包括 Alpha 通道。

java
// 以灰度模式读取图像
Mat grayImage = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);

3.2 显示图像 (HighGui.imshow 或 Swing/JavaFX)

OpenCV 提供了一个简单的 HighGui 类用于显示图像和处理基本的 UI 事件(如按键)。然而,HighGui 在 Java 中的实现可能不稳定,并且功能非常有限。对于严肃的桌面应用程序,通常建议结合 JavaFX 或 Swing 来显示 Mat 对象。

方法一:使用 HighGui (简单但有限)

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.highgui.HighGui;

public class DisplayImageHighGui {
static {
// 加载 OpenCV 本地库
System.loadLibrary(org.opencv.core.Core.NATIVE_LIBRARY_NAME);
}

public static void main(String[] args) {
    String imagePath = "path/to/your/image.jpg"; // 替换为你的图片路径
    Mat image = Imgcodecs.imread(imagePath);

    if (image.empty()) {
        System.out.println("错误: 图像文件无法读取!");
    } else {
        // 创建一个窗口并显示图像
        String windowName = "Displayed Image";
        HighGui.imshow(windowName, image);

        // 等待按键,参数为等待毫秒数,0 表示无限等待直到按键
        HighGui.waitKey(0);

        // 关闭所有 OpenCV 窗口
        HighGui.destroyAllWindows();

        image.release(); // 释放资源
    }
}

}
“`
这种方法非常简单,适合快速测试图像处理效果。但它无法集成到更复杂的 Java GUI 框架中。

方法二:结合 Swing 或 JavaFX (推荐用于桌面应用)

要将 Mat 显示在 Swing 或 JavaFX 组件中,你需要将 Mat 转换为 Java 内置的图像格式,如 java.awt.image.BufferedImage。OpenCV 本身不直接提供 MatBufferedImage 的转换,你需要自己实现这个转换逻辑,或者使用第三方库(如 JavaCV 提供了更方便的转换方法)。

以下是一个简单的将 Mat 转换为 BufferedImage 的逻辑示例(适用于 CV_8UC1CV_8UC3 类型):

“`java
import org.opencv.core.Mat;
import org.opencv.core.CvType;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;

public class Mat2BufferedImage {

/**
 * 将 OpenCV 的 Mat 对象转换为 Java 的 BufferedImage 对象
 * 支持 CV_8UC1 (灰度) 和 CV_8UC3 (BGR 彩色)
 */
public static BufferedImage toBufferedImage(Mat m) {
    int type = BufferedImage.TYPE_BYTE_GRAY; // 默认灰度
    if (m.channels() > 1) {
        // OpenCV 默认是 BGR,而 BufferedImage.TYPE_3BYTE_BGR 是 BGR 格式
        type = BufferedImage.TYPE_3BYTE_BGR;
    }
    int bufferSize = m.channels() * m.cols() * m.rows();
    byte[] b = new byte[bufferSize];
    m.get(0, 0, b); // 获取 Mat 的所有像素数据到 byte 数组

    BufferedImage image = new BufferedImage(m.cols(), m.rows(), type);
    final byte[] targetPixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData();
    System.arraycopy(b, 0, targetPixels, 0, b.length);

    return image;
}

}
“`

然后,你可以在 Swing 应用程序中使用这个转换方法来显示图像:

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import javax.swing.;
import java.awt.image.BufferedImage;
import java.awt.
;

public class DisplayImageSwing {
static {
System.loadLibrary(org.opencv.core.Core.NATIVE_LIBRARY_NAME);
}

public static void main(String[] args) {
    String imagePath = "path/to/your/image.jpg"; // 替换为你的图片路径
    Mat imageMat = Imgcodecs.imread(imagePath);

    if (imageMat.empty()) {
        System.out.println("错误: 图像文件无法读取!");
        return;
    }

    // 将 Mat 转换为 BufferedImage
    BufferedImage imageBuf = Mat2BufferedImage.toBufferedImage(imageMat);

    // 使用 Swing 显示图像
    JFrame frame = new JFrame("Displayed Image (Swing)");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setSize(imageMat.cols(), imageMat.rows()); // 根据图像尺寸设置窗口大小

    JLabel label = new JLabel(new ImageIcon(imageBuf));
    JScrollPane scrollPane = new JScrollPane(label); // 如果图像太大,添加滚动条

    frame.add(scrollPane, BorderLayout.CENTER); // 将滚动面板添加到窗口中间
    frame.pack(); // 根据内容调整窗口大小
    frame.setVisible(true);

    // 注意: Swing 应用通常在事件调度线程中运行,这里主线程执行完毕后,窗口会保持显示
    // 直到用户关闭窗口 (EXIT_ON_CLOSE)

    // 释放 Mat 资源 (在 GUI 程序中,可能需要更精细的资源管理,例如在窗口关闭时释放)
    // 对于简单的示例,可以在 main 函数结束后释放,但这可能不是最佳实践
    // imageMat.release(); // 这里的 release() 可能在窗口关闭前执行,导致问题
    // 更好的做法是在窗口关闭事件中处理资源的释放,或者依赖 GC (但在 Mat 方面不可靠)
    // 鉴于这是一个基础教程,暂时忽略复杂的 GUI 资源管理
}

}
“`
这种方法更灵活,可以将 OpenCV 处理的结果集成到复杂的 Java GUI 应用程序中。

3.3 保存图像 (Imgcodecs.imwrite)

将处理后的 Mat 对象保存到文件也非常简单,使用 Imgcodecs.imwrite() 方法。

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;

// 假设 outputImage 是一个已经处理过的 Mat 对象
Mat outputImage = new Mat(100, 100, org.opencv.core.CvType.CV_8UC3, new org.opencv.core.Scalar(0, 255, 0)); // 创建一个绿色图像作为示例

String outputPath = “path/to/save/output_image.png”; // 指定保存路径和文件名 (扩展名决定格式)

boolean success = Imgcodecs.imwrite(outputPath, outputImage);

if (success) {
System.out.println(“图像成功保存到: ” + outputPath);
} else {
System.out.println(“错误: 图像保存失败!”);
}

outputImage.release(); // 释放资源
``
保存的格式由文件名后缀决定 (
.jpg,.png,.bmp,.tiff等)。imwrite()` 的返回值表示保存操作是否成功。

第四章:图像基本操作

一旦加载了图像,你就可以开始对其进行各种操作了。本章介绍一些最基本和常用的图像操作。

4.1 访问像素

直接访问和修改 Mat 中的单个像素是可能的,但效率较低,尤其是在 Java 中。OpenCV 函数通常针对矩阵操作进行了高度优化。不过,了解如何访问像素对于理解图像数据结构和进行一些简单的调试很有用。

Mat 提供了 get(row, col)put(row, col, data) 方法来访问和修改像素。返回和需要的数据类型取决于 Mat 的类型和通道数。

  • 单通道 (CV_8UC1):
    “`java
    Mat grayMat = new Mat(10, 10, CvType.CV_8UC1, new Scalar(128)); // 灰色图像
    double[] pixel = grayMat.get(0, 0); // 获取 (0, 0) 位置的像素值
    System.out.println(“Pixel value at (0,0): ” + pixel[0]); // 对于单通道,数组只有一个元素

    grayMat.put(0, 0, 255.0); // 设置 (0, 0) 位置的像素值为白色 (255)
    System.out.println(“New pixel value at (0,0): ” + grayMat.get(0, 0)[0]);
    grayMat.release();
    * **三通道 (CV_8UC3):**java
    Mat colorMat = new Mat(10, 10, CvType.CV_8UC3, new Scalar(255, 0, 0)); // 蓝色图像 (BGR)
    double[] colorPixel = colorMat.get(0, 0); // 获取 (0, 0) 位置的像素值
    System.out.println(“Color pixel at (0,0) (BGR): [” + colorPixel[0] + “, ” + colorPixel[1] + “, ” + colorPixel[2] + “]”); // B, G, R

    colorMat.put(0, 0, 0.0, 255.0, 0.0); // 设置 (0, 0) 位置的像素值为绿色 (BGR: 0, 255, 0)
    System.out.println(“New color pixel at (0,0) (BGR): [” + colorMat.get(0, 0)[0] + “, ” + colorMat.get(0, 0)[1] + “, ” + colorMat.get(0, 0)[2] + “]”);
    colorMat.release();
    ``
    请注意,
    get()返回double[]数组,即使原始数据类型不是double。使用put()时,你需要根据Mat的类型和通道数提供相应数量的double` 值。

4.2 颜色空间转换 (Imgproc.cvtColor)

图像可以有不同的颜色表示方式,最常见的是 BGR(OpenCV 默认)、RGB、灰度、HSV、HLS 等。在进行某些图像处理任务(如颜色检测)时,将图像转换到合适的颜色空间是必要的。Imgproc 类提供了 cvtColor() 方法用于颜色空间转换。

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 colorImage 是一个已经读取的彩色 (CV_8UC3) 图像 Mat
String imagePath = “path/to/your/color_image.jpg”;
Mat colorImage = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_COLOR);

if (!colorImage.empty()) {
// 将彩色图像转换为灰度图像
Mat grayImage = new Mat();
Imgproc.cvtColor(colorImage, grayImage, Imgproc.COLOR_BGR2GRAY);

// 将彩色图像转换为 HSV 颜色空间
Mat hsvImage = new Mat();
Imgproc.cvtColor(colorImage, hsvImage, Imgproc.COLOR_BGR2HSV);

System.out.println("原始图像类型: " + colorImage.type() + ", 通道: " + colorImage.channels()); // 例如 16 (CV_8UC3)
System.out.println("灰度图像类型: " + grayImage.type() + ", 通道: " + grayImage.channels());   // 例如 0 (CV_8UC1)
System.out.println("HSV 图像类型: " + hsvImage.type() + ", 通道: " + hsvImage.channels());   // 例如 16 (CV_8UC3)

// ... 对灰度图像或 HSV 图像进行操作 ...

grayImage.release();
hsvImage.release();
colorImage.release(); // 最后释放原始图像

}
``Imgproc.COLOR_BGR2GRAYImgproc.COLOR_BGR2HSV` 是常用的转换代码常量。还有许多其他转换选项,可以在 OpenCV 文档中查找以了解更多。

4.3 尺寸调整 (Imgproc.resize)

改变图像的尺寸(放大或缩小)是常见的操作。Imgproc.resize() 方法可以实现这个功能。

“`java
import org.opencv.core.Mat;
import org.opencv.core.Size;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 originalImage 是一个已经读取的 Mat
String imagePath = “path/to/your/image.jpg”;
Mat originalImage = Imgcodecs.imread(imagePath);

if (!originalImage.empty()) {
// 调整图像尺寸到 300×200 像素
Mat resizedImageFixed = new Mat();
Size newSizeFixed = new Size(300, 200); // 宽度, 高度
Imgproc.resize(originalImage, resizedImageFixed, newSizeFixed, 0, 0, Imgproc.INTER_AREA); // INTER_AREA 适合缩小图像

// 按比例缩小图像到原来的一半
Mat resizedImageScale = new Mat();
double scaleFactor = 0.5;
// 指定 fx 和 fy 缩放因子,newSize 可以设置为 new Size(0, 0)
Imgproc.resize(originalImage, resizedImageScale, new Size(0, 0), scaleFactor, scaleFactor, Imgproc.INTER_LINEAR); // INTER_LINEAR 适合缩放图像

System.out.println("原始图像尺寸: " + originalImage.cols() + "x" + originalImage.rows());
System.out.println("固定尺寸调整后: " + resizedImageFixed.cols() + "x" + resizedImageFixed.rows());
System.out.println("比例缩小后: " + resizedImageScale.cols() + "x" + resizedImageScale.rows());

// ... 对调整后的图像进行操作 ...

resizedImageFixed.release();
resizedImageScale.release();
originalImage.release(); // 最后释放原始图像

}
``Imgproc.resize()的参数说明:
*
src: 输入图像。
*
dst: 输出图像,将在函数内部自动创建。
*
dsize: 目标尺寸 (Size对象),如果设置为new Size(0, 0),则根据fxfy计算目标尺寸。
*
fx: 沿水平方向的缩放因子。
*
fy: 沿垂直方向的缩放因子。
*
interpolation: 插值方法。常用的有Imgproc.INTER_NEAREST(最近邻插值,速度快,效果差),Imgproc.INTER_LINEAR(双线性插值,默认,效果较好),Imgproc.INTER_CUBIC(双三次插值,效果最好但速度慢),Imgproc.INTER_AREA` (区域插值,适合缩小图像)。

4.4 裁剪图像 (Mat.submat)

从图像中截取一部分区域也是常见的需求,可以使用 Mat.submat() 方法实现。submat() 返回的是原始 Mat 的一个视图(或者说子矩阵),它与原始 Mat 共享数据。这意味着修改子矩阵会影响到原始矩阵,反之亦然。

“`java
import org.opencv.core.Mat;
import org.opencv.core.Rect;
import org.opencv.imgcodecs.Imgcodecs;

// 假设 originalImage 是一个已经读取的 Mat
String imagePath = “path/to/your/image.jpg”;
Mat originalImage = Imgcodecs.imread(imagePath);

if (!originalImage.empty()) {
// 定义裁剪区域 (左上角 x, 左上角 y, 宽度, 高度)
// 裁剪区域必须完全在原始图像范围内
int x = 50; // 左上角 x 坐标
int y = 30; // 左上角 y 坐标
int width = 100; // 裁剪宽度
int height = 80; // 裁剪高度

// 检查裁剪区域是否有效
if (x >= 0 && y >= 0 && x + width <= originalImage.cols() && y + height <= originalImage.rows()) {
    Rect cropRect = new Rect(x, y, width, height);
    Mat croppedImage = originalImage.submat(cropRect);

    System.out.println("原始图像尺寸: " + originalImage.cols() + "x" + originalImage.rows());
    System.out.println("裁剪区域尺寸: " + croppedImage.cols() + "x" + croppedImage.rows());

    // ... 对裁剪后的图像进行操作 ...
    // 例如,可以保存裁剪后的图像 (这会创建一个新的 Mat 并复制数据)
    Imgcodecs.imwrite("path/to/save/cropped_image.png", croppedImage);


    // 注意: croppedImage 是 originalImage 的子矩阵,它们共享数据
    // 修改 croppedImage 会修改 originalImage,反之亦然
    // 当不再需要 croppedImage 时,无需调用 release(),因为它是视图,不拥有独立的内存
    // 只需要在不再需要 originalImage 时调用 release()

    originalImage.release(); // 释放原始 Mat 的资源
} else {
    System.out.println("错误: 裁剪区域超出图像范围!");
    originalImage.release();
}

}
``Rect对象定义了裁剪区域的位置和尺寸。submat()返回的是一个新的Mat对象,但这个新对象的数据指针指向原始Mat` 的相应区域。因此,裁剪操作本身非常高效,不涉及大量数据复制。

第五章:常用的图像处理算法

OpenCV 提供了大量的图像处理算法。本章将介绍几个最基础和常用的算法:阈值分割、滤波和边缘检测。

5.1 阈值分割 (Imgproc.threshold)

阈值分割是将图像转换为二值图像(只有黑白两个颜色)的常用方法。它根据像素值与一个阈值的比较结果来决定像素是白色还是黑色。

“`java
import org.opencv.core.Mat;
import org.opencv.core.CvType;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 grayImage 是一个已经读取的灰度 (CV_8UC1) 图像 Mat
String imagePath = “path/to/your/gray_image.jpg”; // 最好使用灰度图像或彩色转灰度
Mat grayImage = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE);

if (!grayImage.empty()) {
Mat binaryImage = new Mat();
double thresholdValue = 128; // 阈值,0-255 之间
double maxPixelValue = 255; // 达到阈值后设置的最大值

// 应用基本二值阈值分割: 像素值 > thresholdValue -> maxPixelValue, 否则 -> 0
Imgproc.threshold(grayImage, binaryImage, thresholdValue, maxPixelValue, Imgproc.THRESH_BINARY);

// 还有其他阈值类型,例如 THRESH_BINARY_INV (反色二值化)
// Imgproc.threshold(grayImage, binaryImage, thresholdValue, maxPixelValue, Imgproc.THRESH_BINARY_INV);

System.out.println("灰度图像类型: " + grayImage.type()); // 0 (CV_8UC1)
System.out.println("二值图像类型: " + binaryImage.type()); // 0 (CV_8UC1)

// ... 对二值图像进行操作 ...

Imgcodecs.imwrite("path/to/save/binary_image.png", binaryImage);

binaryImage.release();
grayImage.release();

}
``Imgproc.threshold()的参数说明:
*
src: 输入图像,通常是灰度图像。
*
dst: 输出的二值图像。
*
thresh: 阈值。
*
maxval: 达到阈值或未达到阈值时设定的最大像素值(通常是 255 对于 8 位图像)。
*
type: 阈值类型。Imgproc.THRESH_BINARY是最基本的类型。还有THRESH_BINARY_INV,THRESH_TRUNC,THRESH_TOZERO,THRESH_TOZERO_INV等。此外,还可以结合THRESH_OTSUTHRESH_TRIANGLE` 来自动计算阈值。

5.2 滤波(平滑/模糊)

滤波是图像处理中用于去除噪声、平滑图像或进行特征提取的常用技术。OpenCV 提供了多种滤波器,如高斯滤波、中值滤波、均值滤波等。

高斯滤波 (Imgproc.GaussianBlur)

高斯滤波使用高斯函数作为权重计算邻域像素的平均值,可以有效地平滑图像并减少噪声,同时保留边缘信息。

“`java
import org.opencv.core.Mat;
import org.opencv.core.Size;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 noisyImage 是一个图像 Mat
String imagePath = “path/to/your/noisy_image.jpg”;
Mat noisyImage = Imgcodecs.imread(imagePath);

if (!noisyImage.empty()) {
Mat blurredImage = new Mat();
Size ksize = new Size(5, 5); // 高斯核大小,必须是奇数正整数
double sigmaX = 0; // 高斯核沿 X 方向的标准差,0 表示根据核大小自动计算
double sigmaY = 0; // 高斯核沿 Y 方向的标准差,0 表示根据核大小自动计算

// 应用高斯滤波
Imgproc.GaussianBlur(noisyImage, blurredImage, ksize, sigmaX, sigmaY);

// ... 对模糊后的图像进行操作 ...

Imgcodecs.imwrite("path/to/save/gaussian_blurred_image.jpg", blurredImage);

blurredImage.release();
noisyImage.release();

}
``ksize是高斯核的尺寸,影响模糊程度,值越大越模糊。sigmaXsigmaY` 是标准差,控制权重分布,通常设置为 0 让 OpenCV 根据核大小自动计算。

中值滤波 (Imgproc.medianBlur)

中值滤波使用邻域像素的中值来代替中心像素的值,特别适用于去除椒盐噪声(Salt-and-pepper noise)。

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 noisyImage 是一个图像 Mat
String imagePath = “path/to/your/noisy_image.jpg”;
Mat noisyImage = Imgcodecs.imread(imagePath);

if (!noisyImage.empty()) {
Mat medianBlurredImage = new Mat();
int ksize = 5; // 核大小,必须是大于 1 的奇数

// 应用中值滤波
Imgproc.medianBlur(noisyImage, medianBlurredImage, ksize);

// ... 对模糊后的图像进行操作 ...

Imgcodecs.imwrite("path/to/save/median_blurred_image.jpg", medianBlurredImage);

medianBlurredImage.release();
noisyImage.release();

}
``
中值滤波的
ksize` 参数是核的边长,必须是大于 1 的奇数。

5.3 边缘检测 (Imgproc.Canny)

边缘检测是识别图像中亮度或颜色急剧变化的区域(即边缘)的过程。Canny 边缘检测是其中一种非常流行的算法,它是一个多阶段算法,包括降噪、计算梯度、非极大值抑制和双阈值边缘连接。

“`java
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;

// 假设 image 是一个图像 Mat
String imagePath = “path/to/your/image.jpg”;
Mat image = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_GRAYSCALE); // Canny 通常在灰度图像上执行

if (!image.empty()) {
Mat edges = new Mat();
double threshold1 = 100; // 第一个阈值
double threshold2 = 200; // 第二个阈值 (通常 threshold1 < threshold2)
int apertureSize = 3; // Sobel 算子的孔径大小,通常为 3

// 应用 Canny 边缘检测
Imgproc.Canny(image, edges, threshold1, threshold2, apertureSize, false); // false 表示不使用 L2 范数计算梯度幅度

// ... 对边缘图像进行操作 ...

// Canny 输出的是 CV_8U 单通道图像,可以直接保存
Imgcodecs.imwrite("path/to/save/canny_edges.png", edges);

edges.release();
image.release();

}
``Imgproc.Canny()的参数说明:
*
image: 输入图像,通常是 8 位单通道(灰度)图像。
*
edges: 输出的边缘图像,与输入图像具有相同大小和类型。
*
threshold1: 第一个阈值。
*
threshold2: 第二个阈值。用于边缘连接:如果像素梯度大于threshold2,则被认为是强边缘像素;如果像素梯度在threshold1threshold2之间,只有当它与强边缘像素相连时才被认为是弱边缘像素;如果像素梯度小于threshold1,则被抑制。
*
apertureSize: Sobel 算子的孔径大小,用于计算图像梯度,通常为 3。
*
L2gradient`: 一个布尔值,指示是否使用 L2 范数计算梯度幅度(默认为 false,使用 L1 范数,更快)。

第六章:简单的应用示例 – 灰度转换并显示

将前面学到的读取、转换和显示结合起来,实现一个简单的程序:读取一张彩色图片,将其转换为灰度图像,并在窗口中显示原始图像和灰度图像。

为了简化显示,我们将使用 HighGui

“`java
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.imgcodecs.Imgcodecs;
import org.opencv.imgproc.Imgproc;
import org.opencv.highgui.HighGui;

public class GrayScaleConverterExample {

static {
    // 尝试加载 OpenCV 本地库
    try {
        System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
        System.out.println("OpenCV 本地库加载成功!");
    } catch (UnsatisfiedLinkError e) {
        System.err.println("OpenCV 本地库加载失败: " + e.getMessage());
        System.err.println("请检查环境配置.");
        System.exit(1); // 退出程序
    }
}

public static void main(String[] args) {
    // 替换为你的彩色图片路径
    String imagePath = "path/to/your/color_image.jpg";

    System.out.println("读取图像: " + imagePath);
    Mat originalImage = Imgcodecs.imread(imagePath, Imgcodecs.IMREAD_COLOR);

    if (originalImage.empty()) {
        System.out.println("错误: 图像文件无法读取或不存在!");
        return;
    }

    System.out.println("图像读取成功! 尺寸: " + originalImage.cols() + "x" + originalImage.rows());

    // 创建一个用于存放灰度图像的 Mat
    Mat grayImage = new Mat();

    // 将彩色图像转换为灰度图像
    System.out.println("正在转换为灰度图像...");
    Imgproc.cvtColor(originalImage, grayImage, Imgproc.COLOR_BGR2GRAY);
    System.out.println("转换完成.");

    // 显示原始图像和灰度图像
    String windowNameOriginal = "Original Image";
    String windowNameGray = "Grayscale Image";

    HighGui.imshow(windowNameOriginal, originalImage);
    HighGui.imshow(windowNameGray, grayImage);

    System.out.println("显示图像中,按任意键关闭窗口...");

    // 等待用户按键
    HighGui.waitKey(0);

    // 关闭窗口并释放资源
    HighGui.destroyAllWindows();
    originalImage.release();
    grayImage.release();
    System.out.println("程序结束.");
}

}
``
运行这个程序,你需要确保:
1. OpenCV 环境已正确搭建,本地库可以被加载。
2.
path/to/your/color_image.jpg替换为你实际存在的彩色图片路径。
3. 编译并运行
GrayScaleConverterExample.java`。

程序将读取图片,转换,然后弹出两个窗口分别显示原图和灰度图。按下任意键后,窗口将关闭,程序结束。

第七章:进阶之路与更多功能(简述)

本教程仅仅触及了 OpenCV 功能的冰山一角。OpenCV 是一个庞大而强大的库,涵盖了计算机视觉领域的许多重要方面。以下是一些你可以继续深入学习的方向和 OpenCV 提供的功能:

  • 图像变换: 仿射变换、透视变换、旋转、缩放等 (Imgproc.warpAffine, Imgproc.getRotationMatrix2D, Imgproc.warpPerspective 等)。
  • 形态学操作: 腐蚀、膨胀、开运算、闭运算等,用于图像的二值处理和结构分析 (Imgproc.erode, Imgproc.dilate, Imgproc.morphologyEx 等)。
  • 直方图: 计算、均衡化、比较等 (Imgproc.calcHist, Imgproc.equalizeHist, Imgproc.compareHist 等),用于图像分析和匹配。
  • 轮廓检测与处理: 查找图像中的轮廓,计算面积、周长、形状匹配等 (Imgproc.findContours, Imgproc.drawContours 等)。
  • 特征检测与匹配: SIFT, SURF (专利问题,在新版本中可能移除或需要 contrib 模块), ORB, AKAZE 等特征点检测器和描述符,以及特征匹配 (Features2d 模块)。
  • 对象检测: 基于 Haar 特征的级联分类器 (如人脸检测),基于深度学习的模型 (如 YOLO, SSD),OpenCV 提供了 DNN 模块支持加载和运行这些模型。
  • 视频处理与分析: 读取、写入视频文件,处理视频帧,背景移除,光流等 (VideoCapture, VideoWriter, BackgroundSubtractor 等)。
  • 相机标定与三维: 相机畸变校正,立体视觉,三维重建等 (Calib3d 模块)。
  • 机器学习: OpenCV 包含一些传统的机器学习算法,如 SVM, K-Means, Decision Trees (ML 模块)。

探索这些功能需要更深入的学习和实践。查阅 OpenCV 官方文档 (https://docs.opencv.org/) 是获取详细信息和函数说明的最佳途径。

第八章:内存管理与最佳实践

在使用 OpenCV Java 接口时,内存管理是一个需要特别关注的问题,主要是因为 Mat 对象内部管理着本地(C++)内存。

8.1 Mat.release() 的重要性

如前所述,务必在你不再需要一个 Mat 对象时调用其 release() 方法。Java 的垃圾回收器只管理 Java 堆内存,不会自动释放 Mat 内部的 C++ 堆内存。频繁创建 Mat 而不释放会导致本地内存泄漏,最终可能导致程序崩溃或性能严重下降。

java
Mat tempMat = new Mat(1000, 1000, CvType.CV_8UC3);
// ... do something with tempMat ...
tempMat.release(); // Important!

8.2 关于视图 (submat()) 和复制 (clone(), copyTo())

  • submat(): 返回的是原始 Mat 的一个区域的视图。它与原始 Mat 共享数据。修改子矩阵会影响原矩阵,反之亦然。释放原矩阵会使得子矩阵失效。因此,对于 submat() 返回的 Mat,你不需要调用 release()。只需确保原始 Mat 在不再使用时被释放即可。
  • clone(): 创建一个 Mat 的完整副本,包括数据。新的 Mat 对象拥有独立的内存。
  • copyTo(otherMat): 将当前 Mat 的数据复制到另一个 Mat 中。如果 otherMat 是空的或尺寸类型不匹配,它会被重新分配。

“`java
Mat original = new Mat(100, 100, CvType.CV_8UC1, new Scalar(100));
Mat sub = original.submat(new Rect(10, 10, 20, 20)); // View
Mat copy = original.clone(); // Deep copy

// 修改 sub 也会修改 original
sub.put(0, 0, 0.0);
System.out.println(“Original (10,10) after modifying sub: ” + original.get(10, 10)[0]); // 会改变

// 修改 copy 不会修改 original
copy.put(0, 0, 0.0);
System.out.println(“Original (0,0) after modifying copy: ” + original.get(0, 0)[0]); // 不改变

original.release(); // 释放 original 会使 sub 失效
// sub.release(); // 不要这样做!
copy.release(); // 释放 copy 的独立内存
“`

8.3 使用 try-finally 或资源管理类 (JavaCV 提供了更好的选择)

虽然标准 opencv-java 库的 Mat 没有实现 AutoCloseable 接口,不能直接用于 try-with-resources,但你可以通过 try-finally 结构来确保 release() 被调用:

“`java
Mat image = null;
Mat gray = null;
try {
image = Imgcodecs.imread(“path/to/image.jpg”);
if (image.empty()) {
System.out.println(“Error loading image”);
return;
}
gray = new Mat();
Imgproc.cvtColor(image, gray, Imgproc.COLOR_BGR2GRAY);

// ... process gray image ...

} finally {
// Ensure resources are released even if exceptions occur
if (image != null) {
image.release();
}
if (gray != null) {
gray.release();
}
}
``
或者,考虑使用 JavaCV ([https://github.com/bytedeco/javacv](https://github.com/bytedeco/javacv))。JavaCV 提供了
opencv-platform依赖,可以更方便地管理本地库,并且其Mat类通常实现了AutoCloseable接口,允许使用try-with-resources` 语法,极大地简化了资源管理:

“`java
// 如果使用 JavaCV
try (Mat image = Imgcodecs.imread(“path/to/image.jpg”);
Mat gray = new Mat()) {

if (image.empty()) {
    System.out.println("Error loading image");
    return;
}

Imgproc.cvtColor(image, gray, Imgproc.COLOR_BGR2GRAY);

// ... process gray image ...

} // 资源在这里自动释放
“`
对于新的项目或需要更便捷的资源管理,考虑使用 JavaCV 是一个不错的选择。它也提供了对其他本地库(如 FFmpeg, Tesseract 等)的 Java 封装。

8.4 性能考虑

  • 避免逐像素操作: 尽可能使用 OpenCV 提供的函数进行图像处理。这些函数在底层通常使用 C++ 编写并经过高度优化(包括 SIMD 指令等),比 Java 代码逐像素操作快得多。
  • 选择合适的数据类型: 根据需要选择合适的 Mat 数据类型。例如,对于普通的图像处理,CV_8UC 类型通常就足够了,它节省内存并提供良好的性能。
  • 内存连续性: 尽量使用连续的 Mat 数据(例如 mat.isContinuous() 为 true)。OpenCV 函数通常对连续数据优化更好。裁剪 (submat()) 和转置可能会破坏连续性。
  • 并行处理: OpenCV 的许多函数内部是并行化的。对于更复杂的流水线,你可以考虑使用 Java 的并发特性结合 OpenCV 函数来提高性能。

结论

恭喜你!通过本教程的学习,你已经掌握了在 Java 项目中使用 OpenCV 的基础知识。我们涵盖了从环境搭建到核心概念 Mat 的理解,以及图像的读取、显示、保存和一些基本处理算法的应用。

计算机视觉是一个充满挑战和机遇的领域。OpenCV 提供了丰富的工具箱,让你能够实现各种复杂的视觉任务。这仅仅是一个开始,鼓励你:

  • 动手实践: 尝试运行并修改本教程中的代码示例,用你自己的图片进行测试。
  • 深入探索: 查阅 OpenCV 官方文档,学习更多高级的函数和模块。
  • 参与社区: 加入 OpenCV 社区,与其他开发者交流学习经验。
  • 构建项目: 将所学知识应用于实际项目,例如开发一个简单的图像编辑器、一个对象识别应用或者一个视频处理工具。

从零开始,你已经迈出了坚实的第一步。祝你在计算机视觉的学习和实践中取得更大的成就!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部