记一次Springboot项目集成opencv进行图像预处理以优化图像二维码识别的过程

背景介绍

项目中已集成了 Hutool 的二维码扩展包(cn.hutool.extra.qrcode)进行二维码识别,但是识别效果总是不理想,主要有两大难题:

  1. 输入图像的质量参差不齐且二维码区域占比大小、位置不固定,导致识别失败;
  2. 包含 Alpha(透明度) 的多通道图像二维码识别容易失败或结果异常。

为了解决以上难题,希望通过 opencv 进行图像预处理,以优化图像二维码识别。

集成 Opencv

一、构建 Opencv Native Lib

Native Lib 称为本地库,在 Java 生态中,通常采用的动态库,因此动态链接库本地库一定程度上可视为是相同的描述。其依赖于操作系统,会因不同操作系统而有所差异。因此需要对不同常见的操作系统构建不同的(本文以 opencv-4.7.0 为例)。
Opencv 官网资源

1、Windows(x64/x86)平台

Opencv 官网提供了 Windows 平台的 Native Lib。可直接前往找到对应版本下载安装即可在安装目录下的 x86x64 目录下分别找到对应的 .dll 后缀的动态链接库(Native Lib)。

2、Linux(类Unix系列) 平台

官方未直接提供 Linux 平台的 Native Lib 资源,需要我们手动基于源文件编译构建。

  • 首先下载 opencv-4.7.0.zip,上传/解压到 Linux 操作系统服务器(家用电脑也ok)~/opencv 目录(其他目录也可,只要当前使用账户具备目录的写入权限)。
  • 然后检查并安装必要的依赖,例如:gcc、gcc-c++、cmake、jdk、ant 等
  • 接着进入 ~/opencv 目录
  • 解压 opencv-4.7.0.zip 至当前目录,并进入
unzip opencv-4.7.0.zip
cd opencv-4.7.0
  • 新建 build 目录,并进入
mkdir build
cd build
  • 生成 makefile,-DANT_EXECUTABLE 替换为本机ant的安装目录,-DCMAKE_INSTALL_PREFIX 替换为自定义的文件夹
cmake -D CMAKE_BUILD_TYPE=RELEASE -DBUILD_SHARED_LIBS=OFF -DWITH_IPP=OFF -DBUILD_ZLIB=OFF -DANT_EXECUTABLE=/usr/bin/ant -DCMAKE_INSTALL_PREFIX=./ ../
  • 以上执行结束后,注意检查最后的输出,如果类似示例中后面五值,说明相关的依赖环境确实或未配置成功,需要逐步检查处理后,删除上一步创建生成的CmakeCache.text文件和CmakeFiles目录,然后重新在 build 目录执行上一步骤
--   Java:                          
--     ant:                         
--     JNI:                         
--     Java wrappers:               
--     Java tests: 
  • 接着执行命令进行编译,命令中的 8 可自行根据本机 cpu 核数对应调整
make -j8
  • 等待编译完成后,执行命令进行安装
make install

安装完成后,进入安装目录(build 目录)

  • 进入 lib 目录,即可找到 libopencv_java470.so 动态链接库(本地库)
  • 进入 bin 目录,即可找到 opencv-470.jar 文件(本次项目集成方式不需要)

3、Mac 平台

官方也未直接提供 mac 平台的 Native Lib 资源,需要我们手动基于源文件编译构建。

(1)安装并更新 Homebrew

确保 Homebrew(macOS 上常用的软件包管理器) 已安装并保持最新,用于安装各种依赖。

  • 未安装,可使用以下命令进行安装
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  • 已安装,可使用以下命令更新
brew update
  • 在 brew update 时,经常会碰到卡死或各种奇葩错误,大多碰到的是 xcode 不是最新或 commandline tool 找不到,可以通过以下方式修复
sudo rm -rf /Library/Developer/CommandLineTools
sudo xcode-select --install
brew update
(2)安装依赖项

OpenCV 编译需要一些开发工具和库。通过 Homebrew 可以安装这些依赖项,可直接通过以下指令一次性安装,如果安装过程出现异常根据依赖项检查问题

brew install gcc git cmake pkg-config ffmpeg libgphoto2 libav libjpeg libpng libtiff libdc1394 ant
  • 确保安装了 1.9 或以上版本的 Ant,如果无法直接使用 ant 指令,需要自己手动在 ~/.bash_profile中将 ant 加入到 path 变量
安装指令
brew install ant
查看版本指令
ant -version
  • 安装 python3(OpenCV4编译 是需要 python3 支持),mac 一般自带 python2,可通过以下指令升级至 python3(python2 也会保留)
brew upgrade python3
  • 安装 JDK 并完成环境变量配置。建议选择可调试的 jdk17,其他版本也应该可行
查看版本
java -version
查看 JAVA_HOME
echo $JAVA_HOME
如果找不到 java 安装路径,可以通过以下指令寻找
which javawhereis java
(3)下载源文件并解压

同 linux 平台,首先需要下载 opencv-4.7.0.zip并解压,示例目录 ~/Downloads/opencv-4.7.0

(4)开始编译构建
  • 进入源文件目录、创建并进入 build 目录
cd ~/Downloads/opencv-4.7.0
mkdir build
cd build
  • 生成 makefile,-DANT_EXECUTABLE 替换为本机ant的安装目录,-DCMAKE_INSTALL_PREFIX 替换为自定义的文件夹
cmake -D CMAKE_BUILD_TYPE=RELEASE -DBUILD_SHARED_LIBS=OFF -DWITH_IPP=OFF -DBUILD_ZLIB=OFF -DANT_EXECUTABLE=/usr/bin/ant -DCMAKE_INSTALL_PREFIX=./ ../
  • 以上执行结束后,注意检查最后的输出,如果类似示例中后面五值,说明相关的依赖环境确实或未配置成功,需要逐步检查处理后,删除上一步创建生成的CmakeCache.text文件和CmakeFiles目录,然后重新在 build 目录执行上一步骤
--   Java:                          
--     ant:                         
--     JNI:                         
--     Java wrappers:               
--     Java tests: 
  • 接着执行命令进行编译,命令中的 8 可自行根据本机 cpu 核数对应调整
make -j8
  • 等待编译完成后,执行命令进行安装
make install

安装完成后,进入安装目录(build 目录)

  • 进入 lib 目录,即可找到 libopencv_java470.dylib 动态链接库(本地库)
  • 进入 bin 目录,即可找到 opencv-470.jar 文件(本次项目集成方式不需要)

二、加载 Opencv Native Lib

为便于使用,选择了将各操作系统的动态链接库(本地库)文件统一放在 resources 下的 opencv 目录,然后在程序运行过程中通过对系统变量 os.name 以及 os.arch 的识别来动态加载与操作系统对应的库文件。

1、库文件

  • libopencv_java470.dylib:mac 平台
  • libopencv_java470.so:linux 平台
  • opencv_java470_x64.dll:windows x64 平台(兼容64位操作系统)
  • opencv_java470_x86.dll:windows x86 平台(兼容32位操作系统)
    springboot项目Opencv库文件

2、加载器

  • OpenCvLoader.loader:执行加载,通过 os.name 以及 os.arch 决定需要加载的库文件,注意 windows 平台 64 位与 32 位库文件后缀相同,可以考虑在文件名或再添加一级子目录的方式来进行区分
  • OpenCvLoader.loadFileNative:遍历文件目录,通过 System.load 加载指定后缀文件
  • OpenCvLoader.loadJarNative:通过创建临时目录文件的方式,加载 Jar 包中的文件(在多模块项目中,如果库文件是放在子模块的 resources 目录下, 需要通过该途径才能找到子模块中的库文件完成加载
  • isLibraryLoaded:已加载的标记,避免重复执行
/**
 * OpenCv Native Lib 加载器
 *
 * @author lihao
 */
public class OpenCvLoader {
    
    

    private static final String NATIVE_PATH = "opencv";

    private static boolean isLibraryLoaded = false;

    /**
     * 加载native dll/so/dylib
     */
    public static void loader() throws IOException {
    
    
        if (isLibraryLoaded) {
    
    
            return;
        }
        Enumeration<URL> dir = Thread.currentThread().getContextClassLoader().getResources(NATIVE_PATH);
        String systemType = System.getProperty("os.name").toLowerCase();
        String arch = System.getProperty("os.arch").toLowerCase();
        System.out.println("当前系统os.name=" + systemType);
        System.out.println("当前系统os.arch=" + arch);
        String ext;
        if (systemType.contains("win")) {
    
    
            if (arch.contains("64")) {
    
    
                ext = "_x64.dll"; // Windows 64位系统
            } else {
    
    
                ext = "_x86.dll"; // Windows 32位系统
            }
        } else if (systemType.contains("nix") || systemType.contains("nux") || systemType.contains("aix")) {
    
    
            ext = ".so";  // Linux系统
        } else if (systemType.contains("mac")) {
    
    
            ext = ".dylib"; // MacOS系统
        } else {
    
    
            throw new UnsupportedOperationException("Unsupported OS: " + systemType);
        }
        while (dir.hasMoreElements()) {
    
    
            URL url = dir.nextElement();
            String protocol = url.getProtocol();
            System.out.println("【opencv目录文件】path:" + url.getPath() + ",protocol:" + protocol);
            if ("jar".equals(protocol)) {
    
    
                JarURLConnection jarURLConnection = (JarURLConnection) url.openConnection();
                JarFile jarFile = jarURLConnection.getJarFile();
                // 遍历Jar包
                Enumeration<JarEntry> entries = jarFile.entries();
                while (entries.hasMoreElements()) {
    
    
                    JarEntry jarEntry = entries.nextElement();
                    String entityName = jarEntry.getName();
                    System.out.println("【opencv目录JAR中文件】entityName:" + entityName + ",isDirectory:" + jarEntry.isDirectory());
                    if (jarEntry.isDirectory() || !entityName.startsWith(NATIVE_PATH)) {
    
    
                        continue;
                    }
                    if (entityName.endsWith(ext)) {
    
    
                        loadJarNative(jarEntry);
                    }
                }
            } else if ("file".equals(protocol)) {
    
    
                File file = new File(url.getPath());
                loadFileNative(file, ext);
            }
        }
    }

    /**
     * 加载 Native File
     *
     * @param file  待加载的文件/文件目录
     * @param ext   文件后缀过滤(仅加载指定文件后缀的文件)
     */
    private static void loadFileNative(File file, String ext) {
    
    
        if (null == file) {
    
    
            return;
        }
        if (file.isDirectory()) {
    
    
            File[] files = file.listFiles();
            if (null != files) {
    
    
                for (File f : files) {
    
    
                    loadFileNative(f, ext);
                }
            }
        }
        if (file.canRead() && file.getName().endsWith(ext)) {
    
    
            try {
    
    
                System.load(file.getPath());
                isLibraryLoaded = true; // 标记库已经加载
                System.out.println("加载native文件 :" + file + "成功!!");
            } catch (UnsatisfiedLinkError e) {
    
    
                e.printStackTrace();
                System.out.println("加载native文件 :" + file + "失败!!请确认操作系统是X86还是X64!!!");
            }
        }
    }

    /**
     * 加载 Jar 中的 Native File
     *
     * @param jarEntry Jar文件
     * @throws IOException IO 异常
     */
    private static void loadJarNative(JarEntry jarEntry) throws IOException {
    
    
        File path = new File(".");
        String rootOutputPath = path.getAbsoluteFile().getParent() + File.separator;
        String entityName = jarEntry.getName();
        File tempFile = new File(rootOutputPath + File.separator + entityName);
        if (!tempFile.getParentFile().exists()) {
    
    
            tempFile.getParentFile().mkdirs();
        }
        if (tempFile.exists()) {
    
    
            tempFile.delete();
        }
        InputStream in = null;
        BufferedInputStream reader = null;
        FileOutputStream writer = null;
        try {
    
    
            in = OpenCvLoader.class.getResourceAsStream(entityName);
            if (in == null) {
    
    
                in = OpenCvLoader.class.getResourceAsStream("/" + entityName);
                if (null == in) {
    
    
                    return;
                }
            }
            reader = new BufferedInputStream(in);
            writer = new FileOutputStream(tempFile);
            byte[] buffer = new byte[1024];

            while (reader.read(buffer) > 0) {
    
    
                writer.write(buffer);
                buffer = new byte[1024];
            }
        } finally {
    
    
            if (in != null) {
    
    
                in.close();
            }
            if (reader != null) {
    
    
                reader.close();
            }
            if (writer != null) {
    
    
                writer.close();
            }
        }
        try {
    
    
            System.load(tempFile.getPath());
            isLibraryLoaded = true; // 标记库已经加载
            System.out.println("加载native文件 :" + tempFile + "成功!!");
        } catch (UnsatisfiedLinkError e) {
    
    
            System.out.println("加载native文件 :" + tempFile + "失败!!请确认操作系统是X86还是X64!!!");
        }
    }

}

3、触发加载

对于 springboot 项目,触发 Opencv Native Lib 加载的时机有两种形式

(1)项目启动时触发加载:可通过实现CommandLineRunner接口触发

/**
 * 项目启动后,加载 OpenCv Native Lib
 *
 * @author lihao
 */
@Component
@Slf4j
public class OpenCvLoaderRunner implements CommandLineRunner {
    
    

    @Override
    public void run(String... args) throws Exception {
    
    
        log.info("start load opencv native lib, start time is " + DateUtil.now());
        OpenCvLoader.loader();
    }
}
(2)需要使用时触发加载:可在封装的工具类 static 域触发
/**
 * OpenCv 工具类
 *
 * @author lihao
 */
public class OpenCvUtils {
    
    

    private OpenCvUtils() {
    
    
    }

    static {
    
    
        try {
    
    
            OpenCvLoader.loader();
        } catch (IOException e) {
    
    
            throw new RuntimeException(e);
        }
    }
    ...
}

三、引入 Opencv JNI(Java Native Interface)

直接通过 maven 引入由 org.openpnp 提供的依赖配置,即可在项目中直接使用 Opencv 相关的接口(注意:要求其版本与 Native Lib 版本对应

	</dependencies>
		...
		<dependency>
			<groupId>org.openpnp</groupId>
			<artifactId>opencv</artifactId>
			<version>4.7.0-0</version>
		</dependency>
		...
	</dependencies>

四、图像转化工具类封装

  • DEBUG 静态常量:设置为true会将图像转换前后的图像保存在指定目录下,便于观察图像的变化效果
  • IMAGE_ROOT_PATH 静态常量:指定图像保存目录
  • 其他方法/函数使用方法见注释说明
/**
 * 图像转化
 *
 * @author lihao
 */
public class ImageConverter {
    
    

    private static final Logger logger = Logger.getLogger(ImageConverter.class.getName());

    private static final boolean DEBUG = false;
    private static final String IMAGE_ROOT_PATH = "/your/path/";
    private static final String IMAGE_FORMAT = "png";

    /**
     * 将 OpenCV 的 Mat 转换为 BufferedImage
     *
     * @param mat OpenCV 的 Mat 对象
     * @param targetType 目标 BufferedImage 类型,传入 -1 则使用默认类型
     * @return 转换后的 BufferedImage 对象
     */
    public static BufferedImage matToBufferedImage(Mat mat, int targetType) {
    
    
        if (DEBUG) {
    
    
            System.out.println("matToBufferedImage-Mat-Channels:" + mat.channels());
            System.out.println("matToBufferedImage-BufferedImage-Type:" + targetType);
            boolean res = Imgcodecs.imwrite(IMAGE_ROOT_PATH + "matToBufferedImage_M." + IMAGE_FORMAT, mat);
            System.out.println("matToBufferedImage-M-Write:" + res);
        }
        int width = mat.cols();
        int height = mat.rows();
        int channels = mat.channels(); // 获取通道数

        // 如果 targetType 为 -1,基于 Mat 通道数选择默认类型
        if (targetType == -1) {
    
    
            if (mat.channels() == 1) {
    
    
                targetType = BufferedImage.TYPE_BYTE_GRAY;
            } else if (mat.channels() == 3) {
    
    
                targetType = BufferedImage.TYPE_INT_RGB; // 默认使用 RGB 顺序
            } else {
    
    
                throw new IllegalArgumentException("Unsupported number of channels: " + channels);
            }
        }

        BufferedImage bufferedImage;
        // 创建 BufferedImage,支持常见类型
        if (targetType == BufferedImage.TYPE_BYTE_GRAY) {
    
    
            bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
        } else if (targetType == BufferedImage.TYPE_3BYTE_BGR) {
    
    
            bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
        } else if (targetType == BufferedImage.TYPE_INT_RGB) {
    
    
            bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        } else {
    
    
            throw new IllegalArgumentException("Unsupported BufferedImage type: " + targetType);
        }

        // 处理 byte[] 类型数据
        if (bufferedImage.getRaster().getDataBuffer() instanceof DataBufferByte) {
    
    
            byte[] data = ((DataBufferByte) bufferedImage.getRaster().getDataBuffer()).getData();
            if (data.length != width * height * channels) {
    
    
                throw new RuntimeException("Data buffer size mismatch.");
            }
            mat.get(0, 0, data); // 从 Mat 中提取数据
            // 处理 BGR 到 RGB 转换
            if (targetType == BufferedImage.TYPE_INT_RGB) {
    
    
                convertBGRtoRGB(data, width, height); // 转换为 RGB 顺序
            }
        }
        // 处理 int[] 类型数据
        else if (bufferedImage.getRaster().getDataBuffer() instanceof DataBufferInt) {
    
    
            int[] data = ((DataBufferInt) bufferedImage.getRaster().getDataBuffer()).getData();
            if (data.length != width * height) {
    
    
                throw new RuntimeException("Data buffer size mismatch.");
            }
            mat.get(0, 0, data); // 从 Mat 中提取数据
            // 处理 BGR 到 RGB 转换
            if (targetType == BufferedImage.TYPE_INT_RGB) {
    
    
                convertBGRtoRGBInt(data, width, height); // 转换为 RGB 顺序
            }
        } else {
    
    
            throw new UnsupportedOperationException("Unsupported data buffer type.");
        }
        if (DEBUG) {
    
    
            try {
    
    
                boolean res = ImageIO.write(
                        bufferedImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "matToBufferedImage_B." + IMAGE_FORMAT));
                System.out.println("matToBufferedImage-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        return bufferedImage;
    }

    /**
     * 将 BufferedImage 转换为 OpenCV 的 Mat
     *
     * @param bufferedImage Java 的 BufferedImage 对象
     * @return 转换后的 OpenCV 的 Mat 对象
     */
    public static Mat bufferedImageToMat(BufferedImage bufferedImage) {
    
    
        if (DEBUG) {
    
    
            System.out.println("bufferedImageToMat-BufferedImage-Type:" + bufferedImage.getType());
            try {
    
    
                boolean res = ImageIO.write(
                        bufferedImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "bufferedImageToMat_B." + IMAGE_FORMAT));
                System.out.println("bufferedImageToMat-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        int type = bufferedImage.getType();
        Mat mat;

        switch (type) {
    
    
            case BufferedImage.TYPE_BYTE_GRAY:
                mat = new Mat(bufferedImage.getHeight(), bufferedImage.getWidth(), CvType.CV_8UC1);
                break;
            case BufferedImage.TYPE_3BYTE_BGR:
                mat = new Mat(bufferedImage.getHeight(), bufferedImage.getWidth(), CvType.CV_8UC3);
                break;
            case BufferedImage.TYPE_INT_RGB:
                mat = new Mat(bufferedImage.getHeight(), bufferedImage.getWidth(), CvType.CV_8UC3);
                // 需要将 RGB 转换为 BGR
                convertRGBtoBGR(bufferedImage, mat);
                break;
            default:
                logger.log(Level.SEVERE, "Unsupported BufferedImage type: " + type);
                throw new IllegalArgumentException("Unsupported BufferedImage type: " + type);
        }

        try {
    
    
            byte[] data = ((DataBufferByte) bufferedImage.getRaster().getDataBuffer()).getData();
            mat.put(0, 0, data);
        } catch (Exception e) {
    
    
            logger.log(Level.SEVERE, "Error converting BufferedImage to Mat", e);
            throw new RuntimeException("Error converting BufferedImage to Mat", e);
        }
        if (DEBUG) {
    
    
            boolean res = Imgcodecs.imwrite(IMAGE_ROOT_PATH + "bufferedImageToMat_M." + IMAGE_FORMAT, mat);
            System.out.println("bufferedImageToMat-M-Write:" + res);
        }
        return mat;
    }

    /**
     * 舍弃 BufferedImage 的 Alpha 通道,并转换为没有 Alpha 通道的图像
     * 支持转换类型:
     * - BufferedImage.TYPE_INT_ARGB -> BufferedImage.TYPE_INT_RGB
     * - BufferedImage.TYPE_4BYTE_ABGR -> BufferedImage.TYPE_3BYTE_BGR
     *
     * @param image 包含 Alpha 通道的图像
     * @return 没有 Alpha 通道的图像
     * @throws IllegalArgumentException 如果输入图像类型不包含 Alpha 通道
     */
    public static BufferedImage stripAlpha(BufferedImage image) {
    
    
        // 检查输入图像类型并执行相应转换
        return switch (image.getType()) {
    
    
            case BufferedImage.TYPE_INT_ARGB ->
                // 将 TYPE_INT_ARGB 转换为 TYPE_INT_RGB
                    convertArgbToRgb(image);
            case BufferedImage.TYPE_4BYTE_ABGR ->
                // 将 TYPE_4BYTE_ABGR 转换为 TYPE_3BYTE_BGR
                    convertAbgrToBgr(image);
            default ->
                // 如果图像类型不包含 Alpha 通道,抛出异常
                    throw new IllegalArgumentException("输入图像类型不包含 Alpha 通道或不支持转换: " + image.getType());
        };
    }

    /**
     * 将 BufferedImage.TYPE_INT_ARGB 转换为 BufferedImage.TYPE_INT_RGB
     * @param argbImage 输入的 ARGB 图像
     * @return 转换后的 RGB 图像
     */
    private static BufferedImage convertArgbToRgb(BufferedImage argbImage) {
    
    
        if (DEBUG) {
    
    
            System.out.println("convertArgbToRgb-input-Type:" + argbImage.getType());
            try {
    
    
                boolean res = ImageIO.write(
                        argbImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "convertArgbToRgb_input_B." + IMAGE_FORMAT));
                System.out.println("convertArgbToRgb-input-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        BufferedImage rgbImage =
                new BufferedImage(argbImage.getWidth(), argbImage.getHeight(), BufferedImage.TYPE_INT_RGB);
        Graphics2D g = rgbImage.createGraphics();
        g.drawImage(argbImage, 0, 0, null);
        g.dispose();
        if (DEBUG) {
    
    
            System.out.println("convertArgbToRgb-output-Type:" + argbImage.getType());
            try {
    
    
                boolean res = ImageIO.write(
                        argbImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "convertArgbToRgb_output_B." + IMAGE_FORMAT));
                System.out.println("convertArgbToRgb-output-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        return rgbImage;
    }

    /**
     * 将 BufferedImage.TYPE_4BYTE_ABGR 转换为 BufferedImage.TYPE_3BYTE_BGR
     * @param abgrImage 输入的 ABGR 图像
     * @return 转换后的 BGR 图像
     */
    private static BufferedImage convertAbgrToBgr(BufferedImage abgrImage) {
    
    
        if (DEBUG) {
    
    
            System.out.println("convertAbgrToBgr-input-Type:" + abgrImage.getType());
            try {
    
    
                boolean res = ImageIO.write(
                        abgrImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "convertAbgrToBgr_input_B." + IMAGE_FORMAT));
                System.out.println("convertAbgrToBgr-input-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        BufferedImage bgrImage =
                new BufferedImage(abgrImage.getWidth(), abgrImage.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
        Graphics2D g = bgrImage.createGraphics();
        g.drawImage(abgrImage, 0, 0, null);
        g.dispose();
        if (DEBUG) {
    
    
            System.out.println("convertAbgrToBgr-output-Type:" + abgrImage.getType());
            try {
    
    
                boolean res = ImageIO.write(
                        abgrImage,
                        IMAGE_FORMAT,
                        new File(IMAGE_ROOT_PATH + "convertAbgrToBgr_output_B." + IMAGE_FORMAT));
                System.out.println("convertAbgrToBgr-output-B-Write:" + res);
            } catch (IOException e) {
    
    
                e.printStackTrace();
            }
        }
        return bgrImage;
    }

    /**
     * 将 BGR 图像数据转换为 RGB
     *
     * @param data 图像数据
     * @param width 图像宽度
     * @param height 图像高度
     */
    private static void convertBGRtoRGB(byte[] data, int width, int height) {
    
    
        int totalPixels = width * height;
        for (int i = 0; i < totalPixels; i++) {
    
    
            byte b = data[i * 3];
            byte r = data[i * 3 + 2];
            data[i * 3] = r;
            data[i * 3 + 2] = b;
        }
    }

    /**
     * 整型像素数组 BGR 到 RGB 转换。
     */
    private static void convertBGRtoRGBInt(int[] data, int width, int height) {
    
    
        for (int i = 0; i < data.length; i++) {
    
    
            int pixel = data[i];
            int red = (pixel >> 16) & 0xFF;
            int blue = pixel & 0xFF;
            // 交换红色和蓝色通道
            data[i] = (pixel & 0xFF00FF00) | (blue << 16) | red;
        }
    }

    /**
     * 将 RGB 图像转换为 BGR
     *
     * @param bufferedImage 输入的 RGB BufferedImage
     * @param mat 输出的 BGR Mat
     */
    private static void convertRGBtoBGR(BufferedImage bufferedImage, Mat mat) {
    
    
        int width = bufferedImage.getWidth();
        int height = bufferedImage.getHeight();
        int[] rgbPixels = bufferedImage.getRGB(0, 0, width, height, null, 0, width);

        byte[] bgrPixels = new byte[width * height * 3];
        for (int i = 0; i < rgbPixels.length; i++) {
    
    
            int rgb = rgbPixels[i];
            bgrPixels[i * 3] = (byte) (rgb & 0xFF); // R -> B
            bgrPixels[i * 3 + 1] = (byte) ((rgb >> 8) & 0xFF); // G -> G
            bgrPixels[i * 3 + 2] = (byte) ((rgb >> 16) & 0xFF); // B -> R
        }

        mat.put(0, 0, bgrPixels);
    }
}

五、图像处理服务类封装

  • DEBUG 静态常量:设置为true会将图像处理的过程图像(例如:缩放图像、灰度图像、二值化图像等)保存在指定目录下,便于观察图像处理效果
  • IMAGE_ROOT_PATH 静态常量:指定过程图像保存目录
  • static 静态域:调用 OpenCvLoader.loader() 加载 Opencv Native Lib,静态域只会执行一次
  • ImagePreprocessingParams 内部类:封装了二维码识别图像处理过程常用的可控参数
  • 其他方法/函数使用方法见注释说明
/**
 * 图像处理服务类
 *
 * @author lihao
 */
@Slf4j
public class ImageService {
    
    

    private static final boolean DEBUG = false;
    private static final String IMAGE_ROOT_PATH = "/your/path/";

    private ImageService() {
    
    
    }

    static {
    
    
        try {
    
    
            OpenCvLoader.loader();
        } catch (IOException e) {
    
    
            throw new RuntimeException(e);
        }
    }

    /**
     * 缩放图像
     *
     * @param srcImage   输入图像
     * @param dstImage   输出图像(缩放后的图像)
     * @param scaleFactor 缩放倍数
     */
    public static void resizeImage(Mat srcImage, Mat dstImage, double scaleFactor) {
    
    
        // 计算缩放后的尺寸
        Size dsize = new Size(srcImage.cols() * scaleFactor, srcImage.rows() * scaleFactor);
        // 使用计算后的尺寸缩放图像
        Imgproc.resize(srcImage, dstImage, dsize);
    }

    /**
     * 亮度和对比度增强
     * 对比度因子(contrastFactor):有效值:大于0的值
     * 1、contrastFactor > 1.0:增加对比度
     * 2、0 < contrastFactor < 1.0:减少对比度
     * 3、contrastFactor = 1.0:保持原对比度(默认)
     * 亮度因子(brightnessFactor):有效值:任何实数值
     * 1、brightnessFactor > 0:增加亮度
     * 2、brightnessFactor < 0:减少亮度
     * 3、brightnessFactor = 0:不改变亮度(默认)
     *
     * @param srcImage         输入图像
     * @param dstImage         输出图像(增强后的图像)
     * @param contrastFactor   对比度因子(增加对比度的倍数)。大于0的值。默认值为 1.0。
     * @param brightnessFactor 亮度因子(增加亮度的值)。任何实数值。默认值为 0。
     */
    public static void enhanceContrastAndBrightness(Mat srcImage,
                                                     Mat dstImage,
                                                     double contrastFactor, double brightnessFactor) {
    
    
        // 验证对比度因子 contrastFactor 的有效性
        if (contrastFactor <= 0) {
    
    
            throw new IllegalArgumentException("Contrast factor (contrastFactor) must be greater than 0.");
        }

        // 增加亮度和对比度
        Core.multiply(srcImage, new Scalar(contrastFactor), dstImage);

        // 调整亮度
        if (brightnessFactor != 0) {
    
    
            Core.add(dstImage, new Scalar(brightnessFactor), dstImage);
        }
    }

    /**
     * 应用高斯模糊以减少图像噪声
     * 小内核(如 3x3):提供轻微的模糊效果,对细节的保留较好。适合轻微去噪。
     * 中等内核(如 5x5 或 7x7):模糊效果更明显,有助于去除中等程度的噪声。
     * 大内核(如 9x9 或更大):模糊效果最强,可以去除较多的噪声,但可能会影响图像的细节。
     *
     * @param srcImage 输入图像
     * @param dstImage 输出图像(模糊后的图像)
     * @param kernelSize 内核大小,必须是奇数,如 3, 5, 7 等
     */
    public static void applyGaussianBlur(Mat srcImage, Mat dstImage, int kernelSize) {
    
    
        if (kernelSize % 2 == 0) {
    
    
            throw new IllegalArgumentException("Kernel size must be an odd number.");
        }
        // 高斯模糊(sigmaX 设置为0,OpenCV 会根据内核的大小自动计算标准差)
        /*
        sigmaX = 0: 使用默认的标准差,根据内核大小自动计算。
        sigmaX > 0: 明确设置标准差,控制模糊的强度。
         */
        Imgproc.GaussianBlur(srcImage, dstImage, new Size(kernelSize, kernelSize), 0);
    }

    /**
     * 膨胀操作
     *
     * @param srcImage 输入图像
     * @param dstImage 输出图像(膨胀后的图像)
     * @param elementSize 结构元素的大小,必须是正奇数,如 3, 5, 7 等
     * @param iterations 膨胀操作的次数,必须是非负整数
     */
    public static void dilateImage(Mat srcImage, Mat dstImage, int elementSize, int iterations) {
    
    
        if (elementSize <= 0 || elementSize % 2 == 0) {
    
    
            throw new IllegalArgumentException("Element size must be a positive odd number.");
        }
        if (iterations < 0) {
    
    
            throw new IllegalArgumentException("Iterations must be a non-negative integer.");
        }

        // 创建结构元素
        Mat element = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(elementSize, elementSize));

        // 执行膨胀操作
        srcImage.copyTo(dstImage); // 先将原图复制到目标图像
        for (int i = 0; i < iterations; i++) {
    
    
            Imgproc.dilate(dstImage, dstImage, element);
            if (DEBUG) {
    
    
                Imgcodecs.imwrite(IMAGE_ROOT_PATH + "7膨胀_" + i +".jpeg", dstImage);
            }
        }

        // 释放结构元素
        element.release();
    }

    /**
     * 判断轮廓是否位于指定区域内
     *
     * @param targetImage 目标图像
     * @param contour     当前轮廓
     * @param xMinFactor  X 轴最小边界因子
     * @param xMaxFactor  X 轴最大边界因子
     * @param yMinFactor  Y 轴最小边界因子
     * @param yMaxFactor  Y 轴最大边界因子
     * @return 是否在指定区域内
     */
    public static boolean isQrCodeInRegion(Mat targetImage, MatOfPoint contour,
                                            double xMinFactor, double xMaxFactor, double yMinFactor, double yMaxFactor) {
    
    
        /*
        m00: 轮廓的面积
        m10 和 m01: 轮廓的重心坐标(质心坐标)
        m20, m02, m11: 轮廓的二阶矩,用于描述轮廓的形状和方向
         */
        Moments mu = Imgproc.moments(contour);
        /*
        计算质心(中心点)

        质心(中心点)可以通过以下公式计算:
        x 坐标:mc.x = m10 / m00
        y 坐标:mc.y = m01 / m00
         */
        Point mc = new Point(mu.m10 / mu.m00, mu.m01 / mu.m00);

        int imageWidth = targetImage.cols();
        int imageHeight = targetImage.rows();

        return mc.x > xMinFactor * imageWidth &&
                mc.x < xMaxFactor * imageWidth &&
                mc.y > yMinFactor * imageHeight &&
                mc.y < yMaxFactor * imageHeight;
    }

    /**
     * 识别二维码
     *
     * @param targetImage               目标图像
     * @param originBufferedImageType   原图像 BufferedImage 类型,传入 -1 则使用Mat默认映射类型
     * @return 解码后的字符串,如果解码失败则返回 null
     */
    public static String decodeQRCode(Mat targetImage, int originBufferedImageType) {
    
    
        BufferedImage qrCodeBufferedImage = ImageConverter.matToBufferedImage(targetImage, originBufferedImageType);
        return decodeQRCode(qrCodeBufferedImage);
    }

    /**
     * 识别二维码
     *
     * @param srcImage  Java 的图像对象
     * @return  识别结果
     */
    public static String decodeQRCode(BufferedImage srcImage) {
    
    
        try {
    
    
            // 假设 QrCodeUtil.decode 方法接收 BufferedImage 并返回解码后的字符串
            return QrCodeUtil.decode(srcImage);
        } catch (QrCodeException e) {
    
    
            log.warn("QR Code not detected or could not be decoded: {}", e.getMessage());
            return null;
        }
    }

    /**
     * 识别二维码
     *
     * @param srcImage  Java 的图像对象
     * @param params    图像处理参数
     * @return  识别结果
     */
    public static String decodeQRCode(BufferedImage srcImage, ImagePreprocessingParams params) {
    
    
        BufferedImage bufferedImage = srcImage;
        if (srcImage.getType() == BufferedImage.TYPE_INT_ARGB ||
                srcImage.getType() == BufferedImage.TYPE_4BYTE_ABGR) {
    
    
            // 如果是4通道图像,降为舍弃掉 Alpha 通道,转化为 对应3通道图像进行处理
            bufferedImage = ImageConverter.stripAlpha(srcImage);
        }
        Mat srcMatImage = ImageConverter.bufferedImageToMat(bufferedImage);
        String decodeText = decodeQRCode(srcMatImage, params, bufferedImage.getType());
        srcMatImage.release(); // 主动释放
        return decodeText;
    }

    /**
     * 识别二维码
     *
     * @param srcImage                  OpenCV 图像对象
     * @param params                    图像处理参数
     * @param originBufferedImageType   原图像 BufferedImage 类型,传入 -1 则使用Mat默认映射类型
     * @return  识别结果
     */
    public static String decodeQRCode(Mat srcImage, ImagePreprocessingParams params, int originBufferedImageType) {
    
    
        // 创建 Mat 对象
        Mat resizeImage = new Mat();
        Mat grayImage = new Mat();
        Mat equalizeHistImage = new Mat();
        Mat contrastandbrightImage = new Mat();
        Mat thresholdImage = new Mat();
        Mat dilateImage = new Mat();

        // 第一步:对 OpenCV 的图像对象进行预处理

        // 1.1、缩放图像
        resizeImage(srcImage, resizeImage, params.resizeFactor);
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "1缩放.jpeg", resizeImage);
        }

        // 1.2、灰度转换
        if (resizeImage.channels() == 4) {
    
    
            Mat intermediateImage = new Mat();
            // 如果是 4 通道图像 (BGRA),首先转换为 3 通道 (BGR)
            Imgproc.cvtColor(resizeImage, intermediateImage, Imgproc.COLOR_BGRA2BGR);
            // 然后将 BGR 图像转换为灰度图像
            Imgproc.cvtColor(intermediateImage, grayImage, Imgproc.COLOR_BGR2GRAY);
        } else if (resizeImage.channels() == 3) {
    
    
            // 如果是 3 通道 BGR 图像,直接转换为灰度图像
            Imgproc.cvtColor(resizeImage, grayImage, Imgproc.COLOR_BGR2GRAY);
        } else if (resizeImage.channels() == 1) {
    
    
            // 如果已经是灰度图像,直接复制
            grayImage = resizeImage.clone();
        } else {
    
    
            throw new UnsupportedOperationException("Unsupported number of channels: " + resizeImage.channels());
        }
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "2灰度.jpeg", grayImage);
        }

        // 1.3、直方图均衡化
        Imgproc.equalizeHist(grayImage, equalizeHistImage);
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "3直方图均衡化.jpeg", equalizeHistImage);
        }
        grayImage.release(); // // 主动释放 grayImage

        // 1.4、亮度和对比度增强
        enhanceContrastAndBrightness(equalizeHistImage, contrastandbrightImage, params.contrastAlpha, params.contrastBeta);
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "4亮度和对比度增强.jpeg", contrastandbrightImage);
        }
        equalizeHistImage.release(); // // 主动释放 equalizeHistImage

        // 1.5、高斯模糊以减少图像噪声
        applyGaussianBlur(contrastandbrightImage, contrastandbrightImage, params.gaussianBlurKernelSize);
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "5高斯模糊.jpeg", contrastandbrightImage);
        }

        // 1.6、二值化
        Imgproc.threshold(contrastandbrightImage, thresholdImage,
                params.binaryThresholdValue, params.binaryMaxValue, params.thresholdMethod);
        if (DEBUG) {
    
    
            Imgcodecs.imwrite(IMAGE_ROOT_PATH + "6二值化.jpeg", thresholdImage);
        }
        contrastandbrightImage.release(); // // 主动释放 contrastandbrightImage

        // 1.7、膨胀操作
        dilateImage(thresholdImage, dilateImage, params.dilateKernelSize, params.dilateIterations);
        thresholdImage.release(); // 主动释放 thresholdImage

        // 2.1、查找轮廓
        List<MatOfPoint> contours = new ArrayList<>();
        /*
        hierarchy 是一个 Mat 类型的矩阵,每个轮廓在该矩阵中对应一个包含四个整数的数组,这四个整数分别表示:
        0、Next: 同一层次结构中的下一个轮廓的索引(即兄弟轮廓),如果没有下一个轮廓,则为 -1。
        1、Previous: 同一层次结构中的前一个轮廓的索引(即兄弟轮廓),如果没有前一个轮廓,则为 -1。
        2、First_Child: 第一个子轮廓的索引,如果没有子轮廓,则为 -1。
        3、Parent: 父轮廓的索引,如果没有父轮廓,则为 -1。
         */
        Mat hierarchy = new Mat();
        Imgproc.findContours(dilateImage, contours, hierarchy, Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE);
        dilateImage.release(); // 主动释放 dilateImage
        hierarchy.release(); // 主动释放 hierarchy

        // 第二步:查找轮廓并检测二维码
        // 使用 parallelStream 处理轮廓
        Optional<String> result = contours.parallelStream()
                .map(contour -> tryDecodeForContour(contour, resizeImage, params, originBufferedImageType))
                .filter(Objects::nonNull)
                .findFirst();
        resizeImage.release(); // 主动释放 resizeImage
        return result.orElse(null);
    }

    /**
     * 尝试识别疑似点对应区域
     *
     * @param contour                   MatOfPoint
     * @param resizeImage               缩放图
     * @param params                    图像处理参数
     * @param originBufferedImageType   原图像 BufferedImage 类型,传入 -1 则使用Mat默认映射类型
     * @return  识别结果
     */
    private static String tryDecodeForContour(MatOfPoint contour,
                                              Mat resizeImage,
                                              ImagePreprocessingParams params,
                                              int originBufferedImageType) {
    
    
        if (Imgproc.contourArea(contour) <= params.minContourArea) {
    
    
            return null;
        }

        // 转换为 MatOfPoint2f
        MatOfPoint2f contour2f = new MatOfPoint2f(contour.toArray());
        if (contour2f.toArray().length < 5) {
    
    
            return null;
        }

        // 椭圆拟合
        RotatedRect rRect = Imgproc.fitEllipse(contour2f);
        double majorAxis = Math.max(rRect.size.height, rRect.size.width);
        double minorAxis = Math.min(rRect.size.height, rRect.size.width);
        float rate = (float) (majorAxis / minorAxis);

        String decodeText = null;
        // 过滤非圆形轮廓
        if (rate < params.minEllipseAxisRatio) {
    
    
            // 判断轮廓是否位于缩放图像的指定区域内
            boolean isInRegion = isQrCodeInRegion(resizeImage, contour,
                    params.regionXMinFactor, params.regionXMaxFactor,
                    params.regionYMinFactor, params.regionYMaxFactor);

            if (isInRegion) {
    
    
                Rect boundingRect = Imgproc.boundingRect(contour);

                // 调整轮廓的最小外接矩形以确保其在图像内
                boundingRect.x = Math.max(boundingRect.x, 0);
                boundingRect.y = Math.max(boundingRect.y, 0);
                boundingRect.width = Math.max(Math.min(boundingRect.width, resizeImage.cols() - boundingRect.x), 0);
                boundingRect.height = Math.max(Math.min(boundingRect.height, resizeImage.rows() - boundingRect.y), 0);

                Mat qrCodeRegion = new Mat(resizeImage, boundingRect);
                if (DEBUG) {
    
    
                    Imgcodecs.imwrite(IMAGE_ROOT_PATH + "8截图_MAT.jpeg", qrCodeRegion);
                }
                decodeText = decodeQRCode(qrCodeRegion, originBufferedImageType);
                qrCodeRegion.release(); // 主动释放
            }
        }
        return decodeText;
    }

    /**
     * 内部类,用于封装二维码识别过程中的可控参数
     */
    public static class ImagePreprocessingParams {
    
    
        /**
         * 缩放因子:用于调整输入图像的大小
         * 通常用于减少图像的分辨率以提高处理速度。
         */
        public final double resizeFactor;

        /**
         * 对比度增强系数:用于调整图像对比度
         * 较大的值会增加图像的对比度,使得图像中的细节更为突出。
         */
        public final double contrastAlpha;

        /**
         * 亮度调节系数:用于调整图像亮度
         * 值越大,图像越亮;值越小,图像越暗。
         */
        public final double contrastBeta;

        /**
         * 高斯模糊内核大小:用于在图像处理中应用高斯模糊
         * 模糊可以减少图像中的噪声,有助于后续的边缘检测和轮廓提取。
         */
        public final int gaussianBlurKernelSize;

        /**
         * 二值化方法:用于将灰度图像转换为二值图像。
         * 可以使用不同的阈值方法,如 Otsu 方法,它会自动选择最佳阈值。
         * 可选值包括:
         * - Imgproc.THRESH_BINARY:应用一个固定的阈值。
         * - Imgproc.THRESH_BINARY_INV:应用一个固定的阈值,并反转输出。
         * - Imgproc.THRESH_TRUNC:将高于阈值的像素设置为阈值,其他像素不变。
         * - Imgproc.THRESH_TOZERO:将低于阈值的像素设置为 0,其他像素不变。
         * - Imgproc.THRESH_TOZERO_INV:将高于阈值的像素设置为 0,其他像素不变。
         */
        public final int thresholdMethod;

        /**
         * 二值化固定阈值:用于将灰度图像转换为二值图像的方法中的固定阈值。
         * 仅在使用固定阈值方法时有效。
         */
        public final double binaryThresholdValue;

        /**
         * 二值化最大像素值:用于二值化处理中的最大像素值。
         * 该值会被用作阈值以上像素的值。
         */
        public final double binaryMaxValue;

        /**
         * 膨胀操作内核大小:用于定义膨胀操作的结构元素的大小
         * 膨胀可以使前景物体变大,填补物体中的小洞。
         */
        public final int dilateKernelSize;

        /**
         * 膨胀操作的迭代次数:控制膨胀操作的重复次数
         * 迭代次数越多,图像的前景物体扩展得越大。
         */
        public final int dilateIterations;

        /**
         * 最小轮廓面积:用于过滤过小的轮廓,避免误检
         * 轮廓面积小于该值的轮廓将被忽略。
         */
        public final double minContourArea;

        /**
         * 最小椭圆长短轴比率:用于过滤非圆形轮廓
         * 如果椭圆的长轴与短轴的比率小于该值,认为它近似于圆形,保留此轮廓。
         */
        public final double minEllipseAxisRatio;

        /**
         * X 轴最小边界因子:用于定义感兴趣区域的 X 轴下界
         * 轮廓质心的 X 坐标要大于图像宽度乘以该因子,才认为轮廓在合法区域内。
         */
        public final double regionXMinFactor;

        /**
         * X 轴最大边界因子:用于定义感兴趣区域的 X 轴上界
         * 轮廓质心的 X 坐标要小于图像宽度乘以该因子,才认为轮廓在合法区域内。
         */
        public final double regionXMaxFactor;

        /**
         * Y 轴最小边界因子:用于定义感兴趣区域的 Y 轴下界
         * 轮廓质心的 Y 坐标要大于图像高度乘以该因子,才认为轮廓在合法区域内。
         */
        public final double regionYMinFactor;

        /**
         * Y 轴最大边界因子:用于定义感兴趣区域的 Y 轴上界
         * 轮廓质心的 Y 坐标要小于图像高度乘以该因子,才认为轮廓在合法区域内。
         */
        public final double regionYMaxFactor;

        private ImagePreprocessingParams(Builder builder) {
    
    
            this.resizeFactor = builder.resizeFactor;
            this.contrastAlpha = builder.contrastAlpha;
            this.contrastBeta = builder.contrastBeta;
            this.gaussianBlurKernelSize = builder.gaussianBlurKernelSize;
            this.thresholdMethod = builder.thresholdMethod;
            this.binaryThresholdValue = builder.binaryThresholdValue;
            this.binaryMaxValue = builder.binaryMaxValue;
            this.dilateKernelSize = builder.dilateKernelSize;
            this.dilateIterations = builder.dilateIterations;
            this.minContourArea = builder.minContourArea;
            this.minEllipseAxisRatio = builder.minEllipseAxisRatio;
            this.regionXMinFactor = builder.regionXMinFactor;
            this.regionXMaxFactor = builder.regionXMaxFactor;
            this.regionYMinFactor = builder.regionYMinFactor;
            this.regionYMaxFactor = builder.regionYMaxFactor;
        }

        public static class Builder {
    
    
            /**
             * 缩放因子:用于调整输入图像的大小
             * 通常用于减少图像的分辨率以提高处理速度。
             * 默认值为 0.3,即缩小为原始大小的 30%。
             */
            private double resizeFactor = 0.3;
            /**
             * 对比度增强系数:用于调整图像对比度
             * 较大的值会增加图像的对比度,使得图像中的细节更为突出。
             * 默认值为 4.0。
             */
            private double contrastAlpha = 4.0;
            /**
             * 亮度调节系数:用于调整图像亮度
             * 值越大,图像越亮;值越小,图像越暗。
             * 默认值为 0,即不调整亮度。
             */
            private double contrastBeta = 0;
            /**
             * 高斯模糊内核大小:用于在图像处理中应用高斯模糊
             * 模糊可以减少图像中的噪声,有助于后续的边缘检测和轮廓提取。
             * 默认值为 3,表示 3x3 的模糊内核。
             */
            private int gaussianBlurKernelSize = 3;
            /**
             * 二值化方法:用于将灰度图像转换为二值图像。
             * 可以使用不同的阈值方法,如 Otsu 方法,它会自动选择最佳阈值。
             * 默认值为 Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU。
             * 可选值包括:
             * - Imgproc.THRESH_BINARY:应用一个固定的阈值。
             * - Imgproc.THRESH_BINARY_INV:应用一个固定的阈值,并反转输出。
             * - Imgproc.THRESH_TRUNC:将高于阈值的像素设置为阈值,其他像素不变。
             * - Imgproc.THRESH_TOZERO:将低于阈值的像素设置为 0,其他像素不变。
             * - Imgproc.THRESH_TOZERO_INV:将高于阈值的像素设置为 0,其他像素不变。
             */
            private int thresholdMethod = Imgproc.THRESH_BINARY_INV + Imgproc.THRESH_OTSU;
            /**
             * 二值化固定阈值:用于将灰度图像转换为二值图像的方法中的固定阈值。
             * 仅在使用固定阈值方法时有效。
             * 默认值为 0。
             */
            private double binaryThresholdValue = 0;

            /**
             * 二值化最大像素值:用于二值化处理中的最大像素值。
             * 该值会被用作阈值以上像素的值。
             * 默认值为 255。
             */
            private double binaryMaxValue = 255;
            /**
             * 膨胀操作内核大小:用于定义膨胀操作的结构元素的大小
             * 膨胀可以使前景物体变大,填补物体中的小洞。
             * 默认值为 3,表示 3x3 的结构元素。
             */
            private int dilateKernelSize = 3;
            /**
             * 膨胀操作的迭代次数:控制膨胀操作的重复次数
             * 迭代次数越多,图像的前景物体扩展得越大。
             * 默认值为 4。
             */
            private int dilateIterations = 4;
            /**
             * 最小轮廓面积:用于过滤过小的轮廓,避免误检
             * 轮廓面积小于该值的轮廓将被忽略。
             * 默认值为 60.0。
             */
            private double minContourArea = 60.0;
            /**
             * 最小椭圆长短轴比率:用于过滤非圆形轮廓
             * 如果椭圆的长轴与短轴的比率小于该值,认为它近似于圆形,保留此轮廓。
             * 默认值为 1.3。
             */
            private double minEllipseAxisRatio = 1.3;
            /**
             * X 轴最小边界因子:用于定义感兴趣区域的 X 轴下界
             * 轮廓质心的 X 坐标要大于图像宽度乘以该因子,才认为轮廓在合法区域内。
             * 默认值为 0.2,即要求质心位于图像宽度的 20% 以上。
             */
            private double regionXMinFactor = 0.2;
            /**
             * X 轴最大边界因子:用于定义感兴趣区域的 X 轴上界
             * 轮廓质心的 X 坐标要小于图像宽度乘以该因子,才认为轮廓在合法区域内。
             * 默认值为 0.8,即要求质心位于图像宽度的 80% 以下。
             */
            private double regionXMaxFactor = 0.8;
            /**
             * Y 轴最小边界因子:用于定义感兴趣区域的 Y 轴下界
             * 轮廓质心的 Y 坐标要大于图像高度乘以该因子,才认为轮廓在合法区域内。
             * 默认值为 0.2,即要求质心位于图像高度的 20% 以上。
             */
            private double regionYMinFactor = 0.2;
            /**
             * Y 轴最大边界因子:用于定义感兴趣区域的 Y 轴上界
             * 轮廓质心的 Y 坐标要小于图像高度乘以该因子,才认为轮廓在合法区域内。
             * 默认值为 0.8,即要求质心位于图像高度的 80% 以下。
             */
            private double regionYMaxFactor = 0.8;

            public Builder setResizeFactor(double resizeFactor) {
    
    
                this.resizeFactor = resizeFactor;
                return this;
            }

            public Builder setContrastAlpha(double contrastAlpha) {
    
    
                this.contrastAlpha = contrastAlpha;
                return this;
            }

            public Builder setContrastBeta(double contrastBeta) {
    
    
                this.contrastBeta = contrastBeta;
                return this;
            }

            public Builder setGaussianBlurKernelSize(int gaussianBlurKernelSize) {
    
    
                this.gaussianBlurKernelSize = gaussianBlurKernelSize;
                return this;
            }

            public Builder setThresholdMethod(int thresholdMethod) {
    
    
                this.thresholdMethod = thresholdMethod;
                return this;
            }

            public Builder setBinaryThresholdValue(double binaryThresholdValue) {
    
    
                this.binaryThresholdValue = binaryThresholdValue;
                return this;
            }

            public Builder setBinaryMaxValue(double binaryMaxValue) {
    
    
                this.binaryMaxValue = binaryMaxValue;
                return this;
            }

            public Builder setDilateKernelSize(int dilateKernelSize) {
    
    
                this.dilateKernelSize = dilateKernelSize;
                return this;
            }

            public Builder setDilateIterations(int dilateIterations) {
    
    
                this.dilateIterations = dilateIterations;
                return this;
            }

            public Builder setMinContourArea(double minContourArea) {
    
    
                this.minContourArea = minContourArea;
                return this;
            }

            public Builder setMinEllipseAxisRatio(double minEllipseAxisRatio) {
    
    
                this.minEllipseAxisRatio = minEllipseAxisRatio;
                return this;
            }

            public Builder setRegionXMinFactor(double regionXMinFactor) {
    
    
                this.regionXMinFactor = regionXMinFactor;
                return this;
            }

            public Builder setRegionXMaxFactor(double regionXMaxFactor) {
    
    
                this.regionXMaxFactor = regionXMaxFactor;
                return this;
            }

            public Builder setRegionYMinFactor(double regionYMinFactor) {
    
    
                this.regionYMinFactor = regionYMinFactor;
                return this;
            }

            public Builder setRegionYMaxFactor(double regionYMaxFactor) {
    
    
                this.regionYMaxFactor = regionYMaxFactor;
                return this;
            }

            public ImagePreprocessingParams build() {
    
    
                return new ImagePreprocessingParams(this);
            }

        }

        public ImagePreprocessingParams.Builder toBuilder() {
    
    
            return new Builder()
                    .setResizeFactor(this.resizeFactor)
                    .setContrastAlpha(this.contrastAlpha)
                    .setContrastBeta(this.contrastBeta)
                    .setGaussianBlurKernelSize(this.gaussianBlurKernelSize)
                    .setThresholdMethod(this.thresholdMethod)
                    .setBinaryThresholdValue(this.binaryThresholdValue)
                    .setBinaryMaxValue(this.binaryMaxValue)
                    .setDilateKernelSize(this.dilateKernelSize)
                    .setDilateIterations(this.dilateIterations)
                    .setMinContourArea(this.minContourArea)
                    .setMinEllipseAxisRatio(this.minEllipseAxisRatio)
                    .setRegionXMinFactor(this.regionXMinFactor)
                    .setRegionXMaxFactor(this.regionXMaxFactor)
                    .setRegionYMinFactor(this.regionYMinFactor)
                    .setRegionYMaxFactor(this.regionYMaxFactor);
        }
    }
}

六、图像解码策略类封装

1、策略接口

/**
 * 图像解码策略
 *
 * @author lihao
 */
public interface DecodeStrategy {
    
    

    /**
     * 解码图像并返回解码后的文本内容。
     *
     * @param bufferedImage 要解码的图像。
     * @return 解码后的文本内容,如果解码失败返回 null。
     */
    String decode(BufferedImage bufferedImage);

}

2、条码号解码实现(未实现)

/**
 * 条码号解码
 *
 * @author lihao
 */
public class BarcodeDecodeStrategy implements DecodeStrategy {
    
    

    @Override
    public String decode(BufferedImage image) {
    
    
        // 这里实现条形码解码逻辑,当前未实现
        throw new UnsupportedOperationException("Barcode decoding is not supported yet.");
    }
}

3、OCR解码实现(未实现)

/**
 * OCR解码
 *
 * @author lihao
 */
public class OCRDecodeStrategy implements DecodeStrategy {
    
    

    @Override
    public String decode(BufferedImage image) {
    
    
        // 这里实现 OCR 解码逻辑,当前未实现
        throw new UnsupportedOperationException("OCR decoding is not supported yet.");
    }
}

4、二维码解码实现

二维码识别的过程大致分为三步:

  • 第一步:通过opencv 图像处理,定位二维码区域
  • 第二步:对符合条件的二维码区域进行截图(降低识别干扰)
  • 第三步:通过 hutool 的 QrCodeUtil.decode 对截图进行二维码识别

能否识别成功很大程度上取决于第一步中的图像处理过程以实现对二维码区域的定位。为了平衡识别的成功率与对服务器资源的压力,在二维码解码策略中设计了以下机制:

  • paramsList 成员变量:策略类图像处理初始化参数组合。在进行图像处理时优先使用该参数,在输入图像形态、质量较稳定的场景可以通过设置固定的参数,减少随机尝试的触发,降低对服务器的资源压力
  • referencePixelThreshold:像素点处理参考阈值,用于结合原图像总像素点数,计算缩放因子。如果过高会消耗更多服务器资源,过低可能会因图像模糊而无法识别二维码
  • minContourAreaRatio:二维码区域占原图的最小比例,用于结合原图像总像素点数、缩放因子,计算最小轮廓面积。如果过高可能导致过滤掉了二维码区域而无法识别,过低会消耗更多服务器资源
  • maxRandomTrials:最大随机尝试次数,用于提升识别的精度。值越大,识别的成功率越高,同时资源上限配置更高
/**
 * 二维码解码策略
 *
 * @author lihao
 */
public class QRCodeDecodeStrategy implements DecodeStrategy {
    
    
    private final List<ImageService.ImagePreprocessingParams> paramsList = new ArrayList<>();
    private final Random random = new Random();
    /**
     * 像素点处理参考阈值
     * 结合原图像总像素点数,计算缩放因子
     */
    private final int referencePixelThreshold;
    /**
     * 二维码区域占原图的最小比例
     * 结合原图像总像素点数、缩放因子,计算最小轮廓面积
     */
    private final double minContourAreaRatio;

    /**
     * 最大随机尝试次数
     */
    private final int maxRandomTrials;

    public QRCodeDecodeStrategy() {
    
    
        this(10);
    }

    public QRCodeDecodeStrategy(int maxRandomTrials) {
    
    
        this(1000 * 1000, 0.05, maxRandomTrials);
    }

    public QRCodeDecodeStrategy(int referencePixelThreshold, double minContourAreaRatio, int maxRandomTrials) {
    
    
        this.referencePixelThreshold = referencePixelThreshold;
        this.minContourAreaRatio = minContourAreaRatio;
        this.maxRandomTrials = maxRandomTrials;
        addDefaultParams();
    }

    public QRCodeDecodeStrategy(List<ImageService.ImagePreprocessingParams> initialParams) {
    
    
        this(10, initialParams);
    }

    public QRCodeDecodeStrategy(int maxRandomTrials, List<ImageService.ImagePreprocessingParams> initialParams) {
    
    
        this(1000 * 1000, 0.05, maxRandomTrials, initialParams);
    }

    public QRCodeDecodeStrategy(int referencePixelThreshold, double minContourAreaRatio, int maxRandomTrials,
                                List<ImageService.ImagePreprocessingParams> initialParams) {
    
    
        this.referencePixelThreshold = referencePixelThreshold;
        this.minContourAreaRatio = minContourAreaRatio;
        this.maxRandomTrials = maxRandomTrials;
        this.paramsList.addAll(initialParams);
    }

    public void addParams(ImageService.ImagePreprocessingParams params) {
    
    
        this.paramsList.add(params);
    }

    @Override
    public String decode(BufferedImage image) {
    
    
        // 尝试使用已有的参数组合
        Optional<String> result = paramsList.parallelStream()
                .map(params -> tryDecode(image, params))
                .filter(Objects::nonNull)
                .findFirst(); // 找到第一个成功的结果立即返回

        if (result.isPresent()) {
    
    
            return result.get();
        }

        // 每次 decode 独立限制最大尝试次数
        int trialCount = 0;

        // 尝试生成新的参数组合
        while (trialCount < maxRandomTrials) {
    
    
            ImageService.ImagePreprocessingParams newParams = generateNewParams(image);
            String decodeResult = tryDecode(image, newParams);

            if (decodeResult != null) {
    
    
                return decodeResult;
            }

            trialCount++; // 增加尝试次数
        }

        return null;
    }

    private String tryDecode(BufferedImage image, ImageService.ImagePreprocessingParams params) {
    
    
        return ImageService.decodeQRCode(image, params);
    }

    private void addDefaultParams() {
    
    
        paramsList.add(new ImageService.ImagePreprocessingParams.Builder()
                /*
                 * 缩放因子:用于调整输入图像的大小
                 * 通常用于减少图像的分辨率以提高处理速度。
                 * 默认值为 0.3,即缩小为原始大小的 30%。
                 */
                .setResizeFactor(0.3) // 缩放需谨慎,可能导致错误识别(错误识别危害大于识别不出)
                /*
                 * 对比度增强系数:用于调整图像对比度
                 * 较大的值会增加图像的对比度,使得图像中的细节更为突出。
                 * 默认值为 2.0。
                 */
                .setContrastAlpha(2.0)
                /*
                 * 亮度调节系数:用于调整图像亮度
                 * 值越大,图像越亮;值越小,图像越暗。
                 * 默认值为 0,即不调整亮度。
                 */
                .setContrastBeta(0)
                /*
                 * 高斯模糊内核大小:用于在图像处理中应用高斯模糊
                 * 模糊可以减少图像中的噪声,有助于后续的边缘检测和轮廓提取。
                 * 默认值为 3,表示 3x3 的模糊内核。
                 */
                .setGaussianBlurKernelSize(3)
                /*
                 * 二值化方法:用于将灰度图像转换为二值图像。
                 * 可选值包括:
                 * - Imgproc.THRESH_BINARY:应用一个固定的阈值。
                 * - Imgproc.THRESH_BINARY_INV:应用一个固定的阈值,并反转输出。
                 * - Imgproc.THRESH_TRUNC:将高于阈值的像素设置为阈值,其他像素不变。
                 * - Imgproc.THRESH_TOZERO:将低于阈值的像素设置为 0,其他像素不变。
                 * - Imgproc.THRESH_TOZERO_INV:将高于阈值的像素设置为 0,其他像素不变。
                 * - 以上5种之一 + Imgproc.THRESH_OTSU:最佳适用场景是图像的直方图具有两个明显的峰值,通过最大化类间方差自动计算阈值
                 * - 以上5种之一 + Imgproc.THRESH_TRIANGLE:最佳适用场景是图像的直方图呈现单峰或具有主要峰的形状,通过几何方法自动确定阈值
                 */
                .setThresholdMethod(Imgproc.THRESH_BINARY_INV)
                /*
                 * 二值化固定阈值:用于将灰度图像转换为二值图像的方法中的固定阈值。
                 * 仅在使用固定阈值方法时有效。
                 * 默认值为 188。
                 */
                .setBinaryThresholdValue(188)
                /*
                 * 二值化最大像素值:用于二值化处理中的最大像素值。
                 * 该值会被用作阈值以上像素的值。
                 * 默认值为 255。
                 */
                .setBinaryMaxValue(255)
                /*
                 * 膨胀操作内核大小:用于定义膨胀操作的结构元素的大小
                 * 膨胀可以使前景物体变大,填补物体中的小洞。
                 * 默认值为 3,表示 3x3 的结构元素。
                 */
                .setDilateKernelSize(3)
                /*
                 * 膨胀操作的迭代次数:控制膨胀操作的重复次数
                 * 迭代次数越多,图像的前景物体扩展得越大。
                 * 默认值为 3。
                 */
                .setDilateIterations(3)
                /*
                 * 最小轮廓面积:用于过滤过小的轮廓,避免误检
                 * 轮廓面积小于该值的轮廓将被忽略。
                 * 默认值为 8000.0。
                 */
                .setMinContourArea(8000)
                /*
                 * 最小椭圆长短轴比率:用于过滤非圆形轮廓
                 * 如果椭圆的长轴与短轴的比率小于该值,认为它近似于圆形,保留此轮廓。
                 * 默认值为 1.3。
                 */
                .setMinEllipseAxisRatio(1.3)
                /*
                 * X 轴最小边界因子:用于定义感兴趣区域的 X 轴下界
                 * 轮廓质心的 X 坐标要大于图像宽度乘以该因子,才认为轮廓在合法区域内。
                 * 默认值为 0.2,即要求质心位于图像宽度的 20% 以上。
                 */
                .setRegionXMinFactor(0.2)
                /*
                 * X 轴最大边界因子:用于定义感兴趣区域的 X 轴上界
                 * 轮廓质心的 X 坐标要小于图像宽度乘以该因子,才认为轮廓在合法区域内。
                 * 默认值为 0.8,即要求质心位于图像宽度的 80% 以下。
                 */
                .setRegionXMaxFactor(0.8)
                /*
                 * Y 轴最小边界因子:用于定义感兴趣区域的 Y 轴下界
                 * 轮廓质心的 Y 坐标要大于图像高度乘以该因子,才认为轮廓在合法区域内。
                 * 默认值为 0.2,即要求质心位于图像高度的 20% 以上。
                 */
                .setRegionYMinFactor(0.2)
                /*
                 * Y 轴最大边界因子:用于定义感兴趣区域的 Y 轴上界
                 * 轮廓质心的 Y 坐标要小于图像高度乘以该因子,才认为轮廓在合法区域内。
                 * 默认值为 0.8,即要求质心位于图像高度的 80% 以下。
                 */
                .setRegionYMaxFactor(0.8)
                .build());
    }

    private ImageService.ImagePreprocessingParams generateNewParams(BufferedImage image) {
    
    
        ImageService.ImagePreprocessingParams.Builder builder = new ImageService.ImagePreprocessingParams.Builder();

        // 定义可能的阈值方法
        int[] thresholdMethods = {
    
    Imgproc.THRESH_OTSU, Imgproc.THRESH_TRIANGLE};
        // 随机选择是否添加额外的阈值方法
        int adaptiveMethod = random.nextBoolean() ? 0 : thresholdMethods[random.nextInt(thresholdMethods.length)];
        // 设置阈值方法
        int thresholdMethod = Imgproc.THRESH_BINARY_INV + adaptiveMethod;

        // 随机生成参数
        builder.setResizeFactor(calculateResizeFactor(image)) // 基于图像计算缩放比例

                .setContrastAlpha(random.nextDouble() * 3.0 + 1.0) // 1.0 到 4.0 之间
                .setContrastBeta(random.nextDouble() * 50.0) // 0 到 50 之间
                .setGaussianBlurKernelSize(random.nextInt(4) * 2 + 1) // 1, 3, 5, 7

                .setThresholdMethod(thresholdMethod) // 随机选择阈值方法
                .setBinaryThresholdValue(random.nextDouble() * 255.0) // 二值化固定阈值(非自适应阈值方法才生效)0 到 255 之间
                .setBinaryMaxValue(255) // 最大值固定为 255

                .setDilateKernelSize(random.nextInt(4) * 2 + 1) // 1, 3, 5, 7
                .setDilateIterations(random.nextInt(4) + 1) // 1 到 4 之间

                .setMinContourArea(calculateMinContourArea(image)) // 基于图像计算最小轮廓面积

                .setMinEllipseAxisRatio(1.3) // 最小椭圆长短轴比率固定1.3

                .setRegionXMinFactor(0.2)
                .setRegionXMaxFactor(0.8)
                .setRegionYMinFactor(0.2)
                .setRegionYMaxFactor(0.8);

        return builder.build();
    }

    /**
     * 获取图像的总像素点个数
     *
     * @param image 图像
     * @return  总像素点个数
     */
    private int getTotalPixels(BufferedImage image) {
    
    
        // 获取图像的宽度和高度
        int width = image.getWidth();
        int height = image.getHeight();
        return width * height;
    }

    /**
     * 计算缩放因子
     *
     * @param image 图像
     * @return 缩放因子
     */
    private double calculateResizeFactor(BufferedImage image) {
    
    
        int totalPixels = getTotalPixels(image);
        double resizeFactor = Math.sqrt((double) referencePixelThreshold / totalPixels);
        return Math.min(1.0, resizeFactor); // 确保缩放因子不会超过1.0(即图像不会放大)
    }

    /**
     * 计算最小轮廓面积
     *
     * @param image 图像
     * @return  最小轮廓面积
     */
    private double calculateMinContourArea(BufferedImage image) {
    
    
        // 获取图像的总像素点数
        double totalPixels = getTotalPixels(image);
        // 计算缩放因子
        double resizeFactor = calculateResizeFactor(image);
        // 计算缩放后的像素点数
        double scaledTotalPixels = totalPixels * resizeFactor * resizeFactor;
        // 计算最小轮廓面积
        return scaledTotalPixels * minContourAreaRatio;
    }
}

猜你喜欢

转载自blog.csdn.net/KsamaLi/article/details/142064956