我的技术学习物语果然有问题
(最后更新 )

Java图片处理

最近经常接触Java的图片处理,所以特地来记录一下。

开发环境

本次处理图片主要使用的依赖是opencv,具体的方法可以去参照官网。

  1. 首先去opencv官网去下载与系统对应的jar包和依赖文件。(这里以windows举例)

  2. 选择windows版本opencv-4.7.0-windows.exe,然后进行安装。(linux的版本需要先进行编译后获取动态库和jar包)

  3. 在安装目录的opencv/build/java的目录下,获得jar包和动态库文件。(注意是安装目录里)

  4. 在maven项目引入opencv依赖。

            <!-- OpenCv  -->
            <dependency>
                <groupId>org.opencv</groupId>
                <artifactId>opencv</artifactId>
                <version>4.7.0</version>
                <scope>system</scope>
                <systemPath>${basedir}/src/main/resources/lib/opencv-470.jar</systemPath>
            </dependency>
  5. 加载opencv动态库

    由于opencv的开发语言问题,所以Java在使用opencv相关类的时候,需要先加载对应的动态库才行,不然直接使用会出现异常。需要注意linux和windows的动态库是不一样的。

好像最新版本的opencv(4.7.0)不支持Java8,只能有Java11,如果用Java8的话,可以使用4.6.0的版本。

如果觉得opencv配置环境过于麻烦,可以使用Javacv,Javacv其实就是调用opencv进行操作的,虽然官方缺少文档(好像速度也慢一些,封装过的也正常),不过和opencv的调用方法都差不多。

linux的编译

上述的动态库dll格式是windows上使用的,要想在对应的linux上使用,还得在linux上编译opencv的源码,获得对应的jar包so动态库文件(jar包应该是不分系统的)。

加载动态库

因为Java调用的opencv都是原生native方法,所以使用opencv的方法之前,需要加载动态库。

    /**
     * 加载opencv动态库
     * win: opencv_java470.dll
     * linux: opencv_java470.so
     */
    public static void load() {
        System.load(ResourceUtil.getResource("lib/opencv_java470.dll").getPath());
    }

每次都加载太过于麻烦,如果使用springboot服务的话,可以在启动服务时初始化导入。

@Configuration
public class InitConfig {

    @PostConstruct
    public void initDll() {
        OpencvUtil.load();
    }

}

opencv基础操作

读入图片

public class Test {

    public static void main(String[] args) {
        OpencvUtil.load();
        String filePath = "C:/Users/11419/Desktop/project/image-process/src/main/resources/test/1.jpg";
        // Mat是opencv表示一张图片的基础类
        Mat mat = Imgcodecs.imread(filePath);

        // Mat其实就是一个像素矩阵
        System.out.println(mat.rows());
        System.out.println(mat.cols());
        for (int i = 0, rows = mat.rows(); i < rows; i++) {
            for (int j = 0, cols = mat.cols(); j < cols; j++) {
                double[] data = mat.get(i, j);
                System.out.println(Arrays.toString(data));
            }
        }
        // 释放内存
        mat.release();
    }

}

写出图片

public class Test {

    public static void main(String[] args) {
        OpencvUtil.load();
        String filePath = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/1.jpg";
        String output = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/2.jpg";

        Mat mat = Imgcodecs.imread(filePath);
        // 写出图片
        Imgcodecs.imwrite(output, mat);
        // 释放内存
        mat.release();
    }

}

Imgcodecs.imread()这个方法,偶然碰见过一次保存失败的情况,成功运行却没有保存文件,所以还可以通过字节方式保存。

    /**
     * 保存图片到指定位置
     *
     * @param mat       图片矩阵
     * @param filePath  文件路径
     */
    public static void saveImage(Mat mat, String filePath) {
        saveImage(mat, filePath, "png");
    }
    
    /**
     * 保存图片到指定位置
     *
     * @param mat       图片矩阵
     * @param filePath  文件路径
     * @param ext       保存文件后缀
     */
    public static void saveImage(Mat mat, String filePath, String ext) {
        MatOfByte matOfByte = new MatOfByte();
        Imgcodecs.imencode("." + ext, mat, matOfByte);
        byte[] byteArray = matOfByte.toArray();
        FileUtil.writeBytes(byteArray, filePath);
    }

释放图片

在Java中使用opencv的话,这个操作比不可少。在opencv的库中,几乎都是native方法,所以导致对象的内存管理大多都不是由Java虚拟机管理,需要手动进行释放内存,不然会出现服务的使用内存不断升高,直至内存不够崩溃。而且期间不会出现内存溢出,dump文件也找不到占用很多内存的Mat类。所以释放图片内存是需要注意的。

public class Test {

    public static void main(String[] args) {
        OpencvUtil.load();
        String filePath = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/1.jpg";
        String output = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/2.jpg";

        Mat mat = Imgcodecs.imread(filePath);
        // 写出图片
        Imgcodecs.imwrite(output, mat);
        // 释放内存
        mat.release();
    }

}

如果你使用了lombok,可以使用注解@Cleanup来进行释放内存的简化。

public class Test {

    public static void main(String[] args) {
        OpencvUtil.load();
        String filePath = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/1.jpg";
        String output = "C:/Users/yww/Desktop/project/image-process/src/main/resources/test/2.jpg";
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(filePath);
        // 写出图片
        Imgcodecs.imwrite(output, mat);
    }

}

获取图片基本信息

获取图片的基本信息,使用opencv并不方便,所以可以使用其他的库来进行。

获取图片DPI

暂时有两种方法可以获取图片的DPI。

  1. commons-imaging依赖

        /**
         * 获取图片的DPI,获取不到返回-1
         * 依赖于commons-imaging
         * <dependency>
         *   <groupId>org.apache.commons</groupId>
         *   <artifactId>commons-imaging</artifactId>
         *   <version>${commons-imaging.version}</version>
         * </dependency>
         *
         * @param filePath  图片文件位置
         * @return          图片DPI
         */
        public static int getDpi1(String filePath) {
            ImageInfo imageInfo = null;
            try {
                imageInfo = Imaging.getImageInfo(FileUtil.file(filePath));
            } catch (ImageReadException | IOException e) {
                throw new RuntimeException("获取图片信息出错!");
            }
            // 水平分辨率和垂直都一样
            if (null == imageInfo) {
                return -1;
            } else {
                return imageInfo.getPhysicalWidthDpi();
            }
        }
    
  2. metadata-extractor依赖

        /**
         * 获取图片的DPI,获取不到返回-1
         * 依赖于metadata-extractor
         * <dependency>
         *   <groupId>com.drewnoakes</groupId>
         *   <artifactId>metadata-extractor</artifactId>
         *   <version>${metadata.version}</version>
         * </dependency>
         *
         * @param filePath  图片文件位置
         * @return          图片DPI
         */
        public static int getDpi2(String filePath) {
            Metadata metadata = null;
            int res = -1;
            try {
                metadata = ImageMetadataReader.readMetadata(FileUtil.file(filePath));
                for (Directory directory : metadata.getDirectories()) {
                    // 遍历图片信息,寻找水平分辨率
                    for (Tag tag : directory.getTags()) {
                        if ("X Resolution".equals(tag.getTagName())) {
                            res = Convert.toInt(tag.getDescription());
                        }
                    }
                }
            } catch (ImageProcessingException | IOException e) {
                throw new RuntimeException("获取图片信息出错!");
            }
            return res;
        }

获取图片分辨率

    /**
     * 获取图片的分辨率
     *
     * @param filePath  图片文件位置
     * @return          [width, height] 水平分辨率 x 垂直分辨率
     */
    public static int[] getResolution(String filePath) {
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(filePath);
        int widthResolution = mat.width();
        int heightResolution = mat.height();
        return new int[]{widthResolution, heightResolution};
    }

这些操作实质是获取图片的基本信息得到的,所以图片大部分的基本信息都可以通过这些方法修改得到。

图片旋转

Graphics2D旋转

    /**
     * 使用Graphics2D进行旋转图片
     *
     * @param src       输入路径
     * @param dst       输出路径
     * @param degree    旋转角度
     */
    public static void rotateImage(String src, String dst, double degree) {
        // 读取图片
        BufferedImage image = ImgUtil.read(src);

        // 获取图片宽,高,类型
        int width = image.getWidth();
        int height = image.getHeight();
        int type = image.getType();
        // 创建Graphics2D
        BufferedImage res = new BufferedImage(width, height, type);
        Graphics2D graphics  = res.createGraphics();
        // 设置图形渲染选项,指定双线性插值算法作为图像缩放时的默认算法
        graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        // 设置图片底色
        graphics.setBackground(Color.WHITE);
        // 填充底图
        graphics.fillRect(0, 0, width, height);
        // 按中心点旋转图片
        graphics.rotate(Math.toRadians(degree), width >> 1, height >> 1);
        graphics.drawImage(image, 0, 0, null);
        // 关闭Graphics2D
        graphics.dispose();

        // 写出图片
        ImgUtil.write(res, FileUtil.file(dst));
    }

opencv旋转

    /**
     * 图片旋转
     *
     * @param image 输入图片
     * @param angle 旋转角度
     * @return      输出图片
     */
    public static Mat rotate(Mat image, double angle) {
        int w = image.cols();
        int h = image.rows();
> 这种方法自定义程度很高,但是消耗的性能也会增加,暂无需求的话推荐使用opencv的方法。

### opencv方法

```Java
    /**
     * 图片旋转
     *
     * @param image 输入图片
     * @param angle 旋转角度
     * @return      输出图片
     */
    public static Mat rotate(Mat image, double angle) {
        int w = image.cols();
        int h = image.rows();

        // 定义参数
        double scale = 1.0;
        Point center = new Point((double) w / 2, (double) h / 2);

        // 定义旋转矩阵(需要注意的是此处正角度是逆时针旋转,所以取反)
        @Cleanup(value = "release")
        Mat rotationMatrix = Imgproc.getRotationMatrix2D(center, -angle, scale);

        // 旋转矩阵
        Mat rotatedImage = new Mat();
        Scalar backgroundColor = new Scalar(255, 255, 255);
        Imgproc.warpAffine(image, rotatedImage, rotationMatrix, new Size(w, h), Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, backgroundColor);

        return rotatedImage;
    }
    if (angle < 0) {
        angle = 360 + angle;
    }
    Point center = new Point((double) w / 2, (double) h / 2);
    double scale = 1.0;

    Mat rotationMatrix = Imgproc.getRotationMatrix2D(center, angle, scale);

    // 设置填充颜色为白色
    Scalar backgroundColor = new Scalar(255, 255, 255);

    Mat rotatedImage = new Mat();
    Imgproc.warpAffine(image, rotatedImage, rotationMatrix, new Size(w, h), Imgproc.INTER_LINEAR, Core.BORDER_CONSTANT, backgroundColor);

    rotationMatrix.release();
    return rotatedImage;
}

## 纠正图片旋转

有些时候,电脑会自动对一些图片进行旋转显示(源文件不变),比如手机拍的一些图片,竖着拍的,电脑显示是竖着的,但是源文件其实是横着的,进行图片旋转,其实是按横着的源文件图片进行旋转,所以有时候会感觉旋转角度不对,所以需要先纠正图片旋转。

```Java
    /**
     * 纠正图片旋转
     * 有些图片手机拍摄,在电脑上看是竖着的,其实是电脑自动进行的转换
     * 如果旋转这种图片,就会变成原来初始的效果进行旋转,和视图的旋转不一样
     * 所以需要先进行纠正图片的旋转
     *
     * @param srcImgPath    图片路径
     */
    public static void correctImg(String srcImgPath) {
        FileOutputStream fos = null;
        try {
            // 原始图片
            File srcFile = new File(srcImgPath);
            // 获取偏转角度
            int angle = getAngle(srcFile);
            if (angle != 90 && angle != 270) {
                return;
            }
            // 原始图片缓存
            BufferedImage srcImg = ImageIO.read(srcFile);
            // 宽高互换
            // 原始宽度
            int imgWidth = srcImg.getHeight();
            // 原始高度
            int imgHeight = srcImg.getWidth();
            // 中心点位置
            double centerWidth = ((double) imgWidth) / 2;
            double centerHeight = ((double) imgHeight) / 2;
            // 图片缓存
            BufferedImage targetImg = new BufferedImage(imgWidth, imgHeight, BufferedImage.TYPE_INT_RGB);
            // 旋转对应角度
            Graphics2D graphics = targetImg.createGraphics();
            graphics.rotate(Math.toRadians(angle), centerWidth, centerHeight);
            graphics.drawImage(srcImg, (imgWidth - srcImg.getWidth()) / 2, (imgHeight - srcImg.getHeight()) / 2, null);
            graphics.rotate(Math.toRadians(-angle), centerWidth, centerHeight);
            graphics.dispose();
            // 输出图片
            fos = new FileOutputStream(srcFile);
            ImageIO.write(targetImg, "jpg", fos);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (fos != null) {
                try {
                    fos.flush();
                    fos.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * 获取图片旋转角度
     *
     * @param file  上传图片
     * @return      图片旋转角度
     */
    private static int getAngle(File file) throws Exception {
        Metadata metadata = ImageMetadataReader.readMetadata(file);
        for (Directory directory : metadata.getDirectories()) {
            for (Tag tag : directory.getTags()) {
                if ("Orientation".equals(tag.getTagName())) {
                    String orientation = tag.getDescription();
                    if (orientation.contains("90")) {
                        return 90;
                    } else if (orientation.contains("180")) {
                        return 180;
                    } else if (orientation.contains("270")) {
                        return 270;
                    }
                }
            }
        }
        return 0;
    }

灰度化

大多数彩色图片的每个像素都是由红绿蓝三个通道组成的,为了更方便的处理图像,将这三个通道的值按一定比例进行加权平均,得到一个单通道的灰度值,用来表示该像素的颜色亮度。大多数图片处理都会先进行图片灰度化,因为只有一个通道,处理起来方便很多。opencv的灰度化操作,只需要调用方法即可。灰度化操作的话,主要是要考虑图片通道数量的问题。

    /**
     * 图片灰度化
     *
     * @param mat       图像矩阵
     * @return          图像矩阵
     */
    public static Mat gray(Mat mat) {
        Mat gray = new Mat();
        // 获取图片的通道数,根据不同通道进行处理
        int channel = mat.channels();

        if (channel == 1) {
            // 单通道图片无需进行灰度化操作
            gray = mat.clone();
        } else if (channel == 2) {
            // 双通道图片,将两个通道的值相加再除以2,得到灰度值
            @Cleanup(value = "release")
            Mat temp = new Mat();
            Core.addWeighted(mat, 0.5, mat, 0.5, 0, temp);
            Imgproc.cvtColor(temp, gray, Imgproc.COLOR_BGR2GRAY);
        } else if (channel == 4 || channel == 3) {
            // 三通道和四通道的图片,使用标准的灰度化方式,考虑Alpha分量
            Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY);
        } else {
            // 其他通道,暂不支持灰度化操作
            throw new UnsupportedOperationException("不支持灰度化的通道数: -->" + channel);
        }
        return gray;
    }

高斯滤波

处理图像时,往往需要对图像进行平滑操作以去除噪声,同时也可以模糊图像以达到柔化的效果。高斯滤波(Gaussian blur)就是一种常用的平滑滤波器,其通过对图像像素进行加权平均的方式来实现平滑和模糊的效果。高斯滤波的原理是使用一个高斯核(Gaussian kernel)对图像像素进行加权平均。一般来说,高斯核的大小或标准差越大,平滑效果越明显,图像的细节丢失也会越多。

    /**
     * 高斯滤波平滑
     * 第三个参数为高斯核大小
     * 第四个和第五个参数为标准差,设置为0,则根据核大小自动计算
     *
     * @param mat       图像矩阵
     * @return          图像矩阵
     */
    public static Mat gaussianBlur(Mat mat) {
        Mat blurred = mat.clone();
        Imgproc.GaussianBlur(mat, blurred, new Size(3, 3), 0, 0);
        return blurred;
    }

图像二值化

图像二值化很好理解,就是把一张彩色图片或者灰度图片转化为黑白二值图像,即每个像素只有两种可能的选值,通常是黑色(0)和白色(255)。主要用于分割某些图片的用途。

二值化具体步骤:

  1. 灰度化。这一步是因为在大多数情况下,只考虑像素的亮度信息就足够了,灰度图像的每个像素值表示亮度的强度,通常在0(黑色)到255(白色)之间。
  2. 选择一个阈值,阈值是一个灰度值,用于判断像素应该被归类为黑色还是白色。选值方法有很多,大概有以下三种
    • 全局阈值:简单地选择一个固定的全局阈值,将所有像素比较与该阈值,高于阈值的像素设置为白色,低于阈值的像素设置为黑色。
    • 局部阈值:根据像素的局部区域来选择阈值,每个像素的阈值根据其周围像素的统计特性(如均值、方差等)来确定。
    • 自适应阈值:根据图像的不同区域自适应地选择阈值。常见的方法有基于局部均值、基于Otsu算法等。
  3. 图片进行二值化处理,对于每个像素,如果其灰度值大于阈值,则将其设为白色(255),否则设为黑色(0)。
    /**
     *  图片二值化处理,参数需要调整
     *
     * @param src       图片矩阵
     * @return          图像矩阵
     */
    public static Mat threshold(Mat src) {
        @Cleanup(value = "release")
        Mat gray = gray(src);
        Mat threshold = new Mat();
        Imgproc.threshold(gray, threshold, 120, 255, Imgproc.THRESH_BINARY);
        return threshold;
    }

图像膨胀与腐蚀

腐蚀与膨胀主要用于处理二值化图像或者是灰度图像。

膨胀

膨胀主要作用是对二值化物体边界点进行扩充,将与物体接触的所有背景点合并到该物体中,使边界向外部扩张。

如果两个物体间隔较近,会将两物体连通在一起。对填补图像分割后物体的空洞有用。

    /**
     *  图片膨胀
     *
     * @param src       图片矩阵
     * @return          图像矩阵
     */
    public static Mat erode(Mat src) {
        Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(5, 5));
        @Cleanup(value = "release")
        Mat gray = gray(src);
        Mat erode = new Mat();
        Imgproc.erode(gray, erode, kernel);
        return erode;
    }

腐蚀

腐蚀主要的作用就是消除物体的边界点,使边界向内收缩,可以把小于结构元素的物体去除。

可将两个有细小连通的物体分开,去除毛刺,小凸起等噪点。如果两个物体间有细小的连通,当结构足够大时,可以将两个物体分开。

    /**
     *  图片腐蚀
     *
     * @param src       图片矩阵
     * @return          图像矩阵
     */
    public static Mat dilate(Mat src) {
        Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(5, 5));
        @Cleanup(value = "release")
        Mat gray = gray(src);
        Mat dilate = new Mat();
        Imgproc.dilate(gray, dilate, kernel);
        return dilate;
    }

边缘检测

边缘是指图像中颜色或亮度发生急剧变化的区域,通常是物体之间的边界或物体内部的纹理。边缘检测算法旨在在图像中找到这些边缘并将其标记出来。具体的步骤如下:

  1. 图像灰度化
  2. 此处还可以进行(高斯模糊,二值化,图片膨胀,图片腐蚀等操作去除噪点)
  3. 进行边缘检测
    /**
     * 边缘检测
     *
     * @param mat   图像矩阵
     * @return      图像矩阵
     */
    public static Mat canny(Mat mat) {
        return canny(mat, 60, 200, 3);
    }

    /**
     *  边缘检测
     *  检测图像中的边缘,并生成一个二值图像,其中边缘被表示为白色,背景为黑色
     *  一般来说threshold2大于threshold1,保证能够检测到真正的边缘
     *  threshold1一般设置为图像灰度级的20%-30%
     *  threshold2一般设置为threshold1的三倍
     *
     * @param mat           图片矩阵
     * @param threshold1    边缘的阈值,用于检测强边缘
     * @param threshold2    边缘的阈值,用于检测弱边缘
     * @param apertureSize  Sobel算子的大小,一般为3,5或7
     * @return          图像矩阵
     */
    public static Mat canny(Mat mat, int threshold1, int threshold2, int apertureSize) {
        // 进行高斯平滑
        @Cleanup(value = "release")
        Mat blurred = new Mat();
        Imgproc.GaussianBlur(mat, blurred, new Size(3, 3), 0, 0);

        // 灰度化
        @Cleanup(value = "release")
        Mat gray = new Mat();
        Imgproc.cvtColor(blurred, gray, Imgproc.COLOR_BGR2GRAY);

        // 进行边缘检测
        Mat canny = new Mat();
        Imgproc.Canny(gray, canny, threshold1, threshold2, apertureSize);

        return canny;
    }

一些图片的实际应用

检测图片亮度

网上关于图片亮度检测的方法,大概有两种。

图片平均亮度值

使用图片的平均灰度值来代表图片的平均亮度。

    /**
     * 计算图片平均亮度
     * mean 获取Mat中各个通道的均值
     *
     * @param filePath  图片路径
     * @return          图片平均亮度值
     */
    public static double brightness(String filePath) {
        Mat src = Imgcodecs.imread(filePath);
        // 灰度化,转为灰度图
        Mat gray = ImageUtil.gray(src.clone());

        // 计算图像的平均亮度
        Scalar mean = Core.mean(gray);
        return mean.val[0];
    }

图片亮度异常值

    /**
     * 图片亮度检测
     * cast为计算出的偏差值,小于1表示比较正常,大于1表示存在亮度异常;当cast异常时,da大于0表示过亮,da小于0表示过暗
     * 标准可以自己定义
     *
     * @param   filePath    图片路径
     * @return              [cast, da] [亮度值, 亮度异常值]
     */
    private double[] brightness2(String filePath) {
        Mat src = Imgcodecs.imread(filePath);
        // 灰度化,转为灰度图
        Mat gray = ImageUtil.gray(src.clone());

        double a = 0;
        int[] hist = new int[256];
        for (int i = 0; i < gray.rows(); i++) {
            for (int j = 0; j < gray.cols(); j++) {
                a += gray.get(i, j)[0] - 128;
                int index = (int) gray.get(i, j)[0];
                hist[index]++;
            }
        }
        double da = a / (gray.rows() * gray.cols());
        double ma = 0;
        for (int i = 0; i < hist.length; i++) {
            ma += Math.abs(i - 128 - da) * hist[i];
        }
        ma = ma / (gray.rows() * gray.cols());
        double cast = Math.abs(da) / Math.abs(ma);
        return new double[] {cast, da};
    }

检测图片的清晰度

网上检测图片清晰度的方法有三种,需要注意的是,这些计算的指标在同一张图片里,越高代表这张图片越清晰。所以可能会出现一种情况,就是有一张模糊的图片指标比有一张清晰的指标高。

Tenengrad梯度

    /**
     * Tenengrad梯度方法计算清晰度
     * Tenengrad梯度方法利用Sobel算子分别计算水平和垂直方向的梯度,同一场景下梯度值越高,图像越清晰。
     *
     * @param image  图片矩阵
     * @return      图片清晰度
     */
    public static double tenengrad(Mat image) {
        // 图片灰度化
        Mat grayImage = image.clone();
        Imgproc.cvtColor(image, grayImage, Imgproc.COLOR_BGR2GRAY);

        // Sobel算子
        Mat sobelImage = new Mat();
        Imgproc.Sobel(grayImage, sobelImage, CvType.CV_16U, 1, 1);

        Scalar mean = Core.mean(sobelImage);
        double meanValue = mean.val[0];

        // 释放内存
        sobelImage.release();
        grayImage.release();

        return meanValue;
    }

Laplacian方法计算清晰度

    /**
     * Laplacian方法计算清晰度
     *
     * @param image  图片矩阵
     * @return      图片清晰度
     */
    public static double laplacian(Mat image) {
        // 图片灰度化
        Mat grayImage = image.clone();
        Imgproc.cvtColor(image, grayImage, Imgproc.COLOR_BGR2GRAY);

        // Laplacian算子
        Mat laplacian = new Mat();
        Imgproc.Laplacian(grayImage, laplacian, CvType.CV_16U);

        Scalar mean = Core.mean(laplacian);
        double meanValue = mean.val[0];

        // 释放内存
        laplacian.release();
        grayImage.release();

        return meanValue;
    }

灰度方差获取图片清晰度

    /**
     * 通过灰度方差获取图片清晰度
     * 对焦清晰的图像相比对焦模糊的图像,它的数据之间的灰度差异应该更大,即它的方差应该较大,可以通过图像灰度数据的方差来衡量图像的清晰度,方差越大,表示清晰度越好。
     *
     * @param image  图片矩阵
     * @return      图片清晰度
     */
    public static double variance(Mat image) {
        // 图片灰度化
        Mat grayImage = image.clone();
        Imgproc.cvtColor(image, grayImage, Imgproc.COLOR_BGR2GRAY);

        // 计算灰度图像的标准差
        MatOfDouble mean = new MatOfDouble();
        MatOfDouble stdDev = new MatOfDouble();
        Core.meanStdDev(grayImage, mean, stdDev);

        double meanValue = stdDev.get(0, 0)[0];

        // 释放内存
        mean.release();
        stdDev.release();
        grayImage.release();

        return meanValue;
    }

图片亮度处理

亮度调整主要是调整平均亮度值到一个自定义的值当中。

    /**
     * 图片亮度调整
     *
     * @param src   输入路径
     * @param dst   输出路径
     */
    public void adjustBrightness(String src, String dst) {
        // 读取图片
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(src);
        // 灰度化,转为灰度图
        @Cleanup(value = "release")
        Mat gray = OpencvUtil.gray(mat.clone());
        // 获取图片平均亮度值
        Scalar mean = Core.mean(gray);
        double brightness = mean.val[0];

        // 图片亮度判断,需要根据具体情况进行判断
        @Cleanup(value = "release")
        Mat adjustedImage = mat.clone();
        if (brightness < 100 || brightness > 250) {
            // 计算亮度调整值,可以根据具体情况选择参数调整
            double alpha = 175 / brightness;
            // 执行亮度调整
            gray.convertTo(adjustedImage, -1, alpha, 0);
        }
        // 保存调整后的图像
        Imgcodecs.imwrite(dst, adjustedImage);
    }

图片纠偏

图片纠偏是一个比较难的问题,主要的难点在于如何计算出图片的倾斜角度,最常见的方法就是霍夫变换检测直线了,以下是具体步骤。

  1. 图片边缘检测
  2. 霍夫变换,获取检测的直线
  3. 计算每条直线的倾斜角度
  4. 获取所有直线倾斜角度的平均值或者是众数来作为图片的倾斜角度。(关于平均值和众数,总会在某些情况下十分异常,我认为平均数比较可靠,但需要严格筛选需要内容的直线才行。也可以根据某些算法排除掉异常值。)
  5. 图片灰度化。
  6. 高斯模糊,二值化,膨胀,腐蚀等操作处理图片,去除噪点,以免出现不需要的直线。
  7. 图片边缘检测,通过调整参数,把一些无用的边框去除。
  8. 霍夫变换检测直线,通过调整参数把一些无用的直线去除。
  9. 收集并计算每条直线的倾斜角度。
  10. 通过每条直线的倾斜角度,推测出整体图片的倾斜角度。这里有很多种方法,比如平均值,众数,出现次数最多的数,或者是附近角度最多的数等等方法。这里使用了出现次数最多的数用来代替图片整体倾斜角度。
    /**
     * 图片进行纠偏
     *
     * @param src   图片路径
     * @param dst   纠偏图片保存路径
     */
    public static void deskew(String src, String dst) {
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(src);
        // 计算图片倾斜角
        double angle = getDeskewAngle(mat);
        // 图片旋转
        ImageUtil.rotateImage(src, dst, angle);
    }

    /**
     *  通过霍夫变换后,获取直线并计算出整体的倾斜角度
     *
     * @param src       图片
     * @return          倾斜角度
     */
    public static Integer getDeskewAngle(Mat src) {
        // 图片灰度化
        Mat gray = src.clone();
        Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
        
        Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(5, 5));

        // 图片膨胀
        Mat erode = gray.clone();
        Imgproc.erode(gray, erode, kernel);

        // 图片腐蚀
        Mat dilate = erode.clone();
        Imgproc.dilate(erode, dilate, kernel);
    public static Integer getDeskewAngle(Mat src) {
        // 图片灰度化
        @Cleanup(value = "release")
        Mat gray = src.clone();
        Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY);
        // 边缘检测
        Mat canny = dilate.clone();
        Imgproc.Canny(dilate, canny, 50, 150);

        // 霍夫变换得到线条
        @Cleanup(value = "release")
        Mat kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(5, 5));

        // 图片膨胀
        @Cleanup(value = "release")
        Mat erode = gray.clone();
        Imgproc.erode(gray, erode, kernel);

        // 图片腐蚀
        @Cleanup(value = "release")
        Mat dilate = erode.clone();
        Imgproc.dilate(erode, dilate, kernel);

        // 边缘检测
        @Cleanup(value = "release")
        Mat canny = dilate.clone();
        Imgproc.Canny(dilate, canny, 50, 150);

        // 霍夫变换得到线条
        @Cleanup(value = "release")
        Mat lines = new Mat();
        //累加器阈值参数,小于设置值不返回
        int threshold = 90;
        //最低线段长度,低于设置值则不返回
        double minLineLength = 100;
        //间距小于该值的线当成同一条线
        double maxLineGap = 10;
        // 霍夫变换,通过步长为1,角度为PI/180来搜索可能的直线
        Imgproc.HoughLinesP(canny, lines, 1, Math.PI / 180, threshold, minLineLength, maxLineGap);
        // 计算倾斜角度
        List<Integer> angelList = new ArrayList<>();
        for (int i = 0; i < lines.rows(); i++) {
            double[] line = lines.get(i, 0);
            int k = calculateAngle(line[0], line[1], line[2], line[3]);
            angelList.add(k);
        }
        if (angelList.isEmpty()) {
            return 0;
        }
        gray.release();
        kernel.release();
        erode.release();
        dilate.release();
        canny.release();
        lines.release();
        // 可能还得需要考虑方差来决定选择平均数还是众数
        return most(angelList);
    }


    /**
     * 求数组众数
     *
     * @param angelList 数组
     * @return          数组众数
     */
    private static int most(List<Integer> angelList) {
        if (angelList.isEmpty()) {
            return 0;
        }
        int res = 0;
        int max = Integer.MIN_VALUE;
        Map<Integer, Integer> map = new HashMap<>();
        for (int i : angelList) {
            map.put(i, map.getOrDefault(i, 0) + 1);
        }
        for (Integer i : map.keySet()) {
            int count = map.get(i);
            if (count > max) {
                max = count;
                res = i;
            }
        }
        return res;
    }

    /**
     * 计算直线的倾斜角
     *
     * @param x1    点1的横坐标
     * @param x2    点2的横坐标
     * @param y1    点1的纵坐标
     * @param y2    点2的纵坐标
     * @return 直角的倾斜度
     */
    private static int calculateAngle(double x1, double y1, double x2, double y2) {
        double dx = x2 - x1;
        double dy = y2 - y1;
        if (Math.abs(dx) < 1e-4) {
            return 90;
        } else if (Math.abs(dy) < 1e-4) {
            return 0;
        } else {
            double radians = Math.atan2(dy, dx);
            double degrees = Math.toDegrees(radians);
            return Convert.toInt(Math.round(degrees));
        }
    }

去除红色印章

有很多场景,比如说发票,白底黑字文件这种,在OCR识别的过程中,会受到红色印章的影响,所以需要先去除红色印章,在进行OCR识别,这样可以提高OCR的识别率。

红色印章检测

我碰到比较多的场景是白底黑字的图片,所以我这里就使用了检测红色像素,痛过红色像素的数量,来检测是否该图片有红色印章。

    /**
     * 遍历红色像素,根据像素数量判断是否存在红色印章
     *
     * @param filePath      图片路径
     * @return              true表示可能存在红色印章
     */
    public static Boolean recognizeRed(String filePath) {
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(filePath);
        // 转为HSV空间
        @Cleanup(value = "release")
        Mat hsv = new Mat();
        Imgproc.cvtColor(mat, hsv, Imgproc.COLOR_BGR2HSV);
        int nums = 0;

        for (int i = 0; i < hsv.rows(); i++) {
            for (int j = 0; j < hsv.cols(); j++) {
                double[] clone = hsv.get(i, j).clone();
                double h = clone[0];
                double s = clone[1];
                double v = clone[2];
                // 红色的hsv范围判断
                if ((h > 0 && h < 10) || (h > 156 && h < 180)) {
                    if (s > 43 && s < 255) {
                        if (v < 255 && v > 46) {
                            nums++;
                        }
                    }
                }

            }
        }
        return nums > 8000;
    }

有些图片看着是没有红色像素的,但是其实会存在有,所以先使用一个只有红色印章的图片进行检测,然后选择一个比较合理的值进行判断就可以。

这种方法不适合除了印章外还有很多红色的图片,那种情况可以考虑使用霍夫圆检测,那种可能会耗费很多时间,所以我没有使用。

去除红色印章

我这里去除红色印章的方法,主要的原理就是使用图片二值化,让印章的变成白色即可,但是会出现一个问题,就是红色比黑色更难二值化,所以需要先把图片的红色通道分离出来,红色通道的红色印章的颜色会变淡很多,从而二值化能去除掉红色印章。

    /**
     * 去除红色印章,只是红色通道中的去除,图片会有灰色变化
     *
     * @param filePath  图片路径
     * @param dst       去除后图片保存地址
     */
    public static void removeRed(String filePath, String dst) {
        @Cleanup(value = "release")
        Mat mat = Imgcodecs.imread(filePath);

        // 分离红色通道
        List<Mat> matList = new ArrayList<>();
        Core.split(mat, matList);
        @Cleanup(value = "release")
        Mat red = matList.get(2);

        // 红色通道二值化
        @Cleanup(value = "release")
        Mat redThresh = new Mat();
        Imgproc.threshold(red, redThresh, 120, 255, Imgproc.THRESH_BINARY);

        Imgcodecs.imwrite(dst, redThresh);
    }

这种方法的结果图片,因为去除了蓝绿通道,所以会变得灰色,不过因为主要的目的是给OCR识别,所以无所谓,如果想不改变图片的话,还是挺麻烦的,可能还得识别出椭圆印章,然后精确的去除。

切边矫正

图片的切边矫正还是使用深度学习模型比较准确,使用opencv局限性还是挺大的,简单使用还是可以的。以下的方法借鉴了网上流传比较广的一个关于截取发票的边缘的方法。

    public static void remove(String src,String dst) {
        Mat img = Imgcodecs.imread(src);
        if(img.empty()){
            return;
        }
        Mat greyImg = img.clone();
        //1.彩色转灰色
        Imgproc.cvtColor(img, greyImg, Imgproc.COLOR_BGR2GRAY);
        OpencvUtil.saveImage(greyImg, "C:\\Users\\11419\\Desktop\\test\\1.jpg");

        Mat gaussianBlurImg = greyImg.clone();
        // 2.高斯滤波,降噪
        Imgproc.GaussianBlur(greyImg, gaussianBlurImg, new Size(3,3),0);
        OpencvUtil.saveImage(greyImg, "C:\\Users\\11419\\Desktop\\test\\2.jpg");

        // 3.Canny边缘检测
        Mat cannyImg = gaussianBlurImg.clone();
        Imgproc.Canny(gaussianBlurImg, cannyImg, 50, 200);
        OpencvUtil.saveImage(cannyImg, "C:\\Users\\11419\\Desktop\\test\\3.jpg");

        // 4.膨胀,连接边缘
        Mat dilateImg = cannyImg.clone();
        Imgproc.dilate(cannyImg, dilateImg, new Mat(), new Point(-1, -1), 3, 1, new Scalar(1));
        OpencvUtil.saveImage(dilateImg, "C:\\Users\\11419\\Desktop\\test\\4.jpg");

        //5.对边缘检测的结果图再进行轮廓提取
        List<MatOfPoint> contours = new ArrayList<>();
        List<MatOfPoint> drawContours = new ArrayList<>();
        Mat hierarchy = new Mat();
        Imgproc.findContours(dilateImg, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
        Mat linePic = Mat.zeros(dilateImg.rows(), dilateImg.cols(), CvType.CV_8UC3);
        //6.找出轮廓对应凸包的四边形拟合
        List<MatOfPoint> squares = new ArrayList<>();
        List<MatOfPoint> hulls = new ArrayList<>();
        MatOfInt hull = new MatOfInt();
        MatOfPoint2f approx = new MatOfPoint2f();
        approx.convertTo(approx, CvType.CV_32F);

        for (MatOfPoint contour : contours) {
            // 边框的凸包
            Imgproc.convexHull(contour, hull);
            // 用凸包计算出新的轮廓点
            Point[] contourPoints = contour.toArray();
            int[] indices = hull.toArray();
            List<Point> newPoints = new ArrayList<>();
            for (int index : indices) {
                newPoints.add(contourPoints[index]);
            }
            MatOfPoint2f contourHull = new MatOfPoint2f();
            contourHull.fromList(newPoints);
            // 多边形拟合凸包边框(此时的拟合的精度较低)
            Imgproc.approxPolyDP(contourHull, approx, Imgproc.arcLength(contourHull, true) * 0.02, true);
            MatOfPoint mat = new MatOfPoint();
            mat.fromArray(approx.toArray());
            drawContours.add(mat);
            // 筛选出面积大于某一阈值的,且四边形的各个角度都接近直角的凸四边形
            MatOfPoint approxf = new MatOfPoint();
            approx.convertTo(approxf, CvType.CV_32S);
            if (approx.rows() == 4 && Math.abs(Imgproc.contourArea(approx)) > 40000 &&
                    Imgproc.isContourConvex(approxf)) {
                double maxCosine = 0;
                for (int j = 2; j < 5; j++) {
                    double cosine = Math.abs(getAngle(approxf.toArray()[j % 4], approxf.toArray()[j - 2], approxf.toArray()[j - 1]));
                    maxCosine = Math.max(maxCosine, cosine);
                }
                // 角度大概72度
                if (maxCosine < 0.3) {
                    MatOfPoint tmp = new MatOfPoint();
                    contourHull.convertTo(tmp, CvType.CV_32S);
                    squares.add(approxf);
                    hulls.add(tmp);
                }
            }
        }
        //这里是把提取出来的轮廓通过不同颜色的线描述出来,具体效果可以自己去看
        Random r = new Random();
        for (int i = 0; i < drawContours.size(); i++) {
            Imgproc.drawContours(linePic, drawContours, i, new Scalar(r.nextInt(255),r.nextInt(255), r.nextInt(255)));
        }
        OpencvUtil.saveImage(linePic, "C:\\Users\\11419\\Desktop\\test\\5.jpg");
        //7.找出最大的矩形
        int index = findLargestSquare(squares);
        MatOfPoint largest_square;
        if(!squares.isEmpty()){
            largest_square = squares.get(index);
        }else{
            System.out.println("图片无法识别");
            return;
        }
        Mat polyPic = Mat.zeros(img.size(), CvType.CV_8UC3);
        Imgproc.drawContours(polyPic, squares, index, new Scalar(0, 0,255), 2);
        OpencvUtil.saveImage(polyPic, "C:\\Users\\11419\\Desktop\\test\\6.jpg");
        //存储矩形的四个凸点
        hull = new MatOfInt();
        Imgproc.convexHull(largest_square, hull, false);
        List<Integer> hullList =  hull.toList();
        List<Point> polyContoursList = largest_square.toList();
        List<Point> hullPointList = new ArrayList<>();
        for (Integer integer : hullList) {
            Imgproc.circle(polyPic, polyContoursList.get(integer), 10, new Scalar(r.nextInt(255), r.nextInt(255), r.nextInt(255), 3));
            hullPointList.add(polyContoursList.get(integer));
        }
        Core.addWeighted(polyPic, 1, img, 1, 0, img);
        OpencvUtil.saveImage(img, "C:\\Users\\11419\\Desktop\\test\\7.jpg");
        List<Point> lastHullPointList = new ArrayList<>(hullPointList);
        //dstPoints储存的是变换后各点的坐标,依次为左上,右上,右下, 左下
        //srcPoints储存的是上面得到的四个角的坐标
        Point[] dstPoints = {new Point(0,0), new Point(img.cols(),0), new Point(img.cols(),img.rows()), new Point(0,img.rows())};
        Point[] srcPoints = new Point[4];
        boolean sorted = false;
        int n = 4;
        //对四个点进行排序 分出左上 右上 右下 左下
        while (!sorted && n > 0){
            for (int i = 1; i < n; i++){
                sorted = true;
                if (lastHullPointList.get(i - 1).x > lastHullPointList.get(i).x){
                    Point temp1 = lastHullPointList.get(i);
                    Point temp2 = lastHullPointList.get(i-1);
                    lastHullPointList.set(i, temp2);
                    lastHullPointList.set(i - 1, temp1);
                    sorted = false;
                }
            }
            n--;
        }
        //即先对四个点的x坐标进行冒泡排序分出左右,再根据两对坐标的y值比较分出上下
        if (lastHullPointList.get(0).y < lastHullPointList.get(1).y){
            srcPoints[0] = lastHullPointList.get(0);
            srcPoints[3] = lastHullPointList.get(1);
        }else{
            srcPoints[0] = lastHullPointList.get(1);
            srcPoints[3] = lastHullPointList.get(0);
        }
        if (lastHullPointList.get(2).y < lastHullPointList.get(3).y){
            srcPoints[1] = lastHullPointList.get(2);
            srcPoints[2] = lastHullPointList.get(3);
        }else{
            srcPoints[1] = lastHullPointList.get(3);
            srcPoints[2] = lastHullPointList.get(2);
        }
        List<Point> listSrcs = java.util.Arrays.asList(srcPoints[0], srcPoints[1], srcPoints[2], srcPoints[3]);
        Mat srcPointsMat = Converters.vector_Point_to_Mat(listSrcs, CvType.CV_32F);

        List<Point> dstSrcs = java.util.Arrays.asList(dstPoints[0], dstPoints[1], dstPoints[2], dstPoints[3]);
        Mat dstPointsMat = Converters.vector_Point_to_Mat(dstSrcs, CvType.CV_32F);
        //参数分别为输入输出图像、变换矩阵、大小。
        //坐标变换后就得到了我们要的最终图像。
        Mat transMat = Imgproc.getPerspectiveTransform(srcPointsMat, dstPointsMat);    //得到变换矩阵
        Mat outPic = new Mat();
        Imgproc.warpPerspective(img, outPic, transMat, img.size());
        OpencvUtil.saveImage(outPic, dst);
    }

    // 根据三个点计算中间那个点的夹角   pt1 pt0 pt2
    private static double getAngle(Point pt1, Point pt2, Point pt0) {
        double dx1 = pt1.x - pt0.x;
        double dy1 = pt1.y - pt0.y;
        double dx2 = pt2.x - pt0.x;
        double dy2 = pt2.y - pt0.y;
        return (dx1*dx2 + dy1*dy2)/Math.sqrt((dx1*dx1 + dy1*dy1)*(dx2*dx2 + dy2*dy2) + 1e-10);
    }

    // 找到最大的正方形轮廓
    private static int findLargestSquare(List<MatOfPoint> squares) {
        if (squares.isEmpty()) {
            return -1;
        }
        int maxWidth = 0;
        int maxHeight = 0;
        int maxSquareIdx = 0;
        int currentIndex = 0;
        for (MatOfPoint square : squares) {
            Rect rectangle = Imgproc.boundingRect(square);
            if (rectangle.width >= maxWidth && rectangle.height >= maxWidth) {
                maxWidth = rectangle.width;
                maxHeight = rectangle.height;
                maxSquareIdx = currentIndex;
            }
            currentIndex++;
        }
        return maxSquareIdx;
    }