Android-JNI开发系列《十》实践利用libjpeg-turbo完美压缩图片不失真

人间观察

步入社会后,你会发现,老人说的话都是对的。

前面讲了些Android的jni知识和bitmap的实践,接下来几篇应该都是Android中jni的一些实践。这篇我们对Android中图片在jni层利用libjpeg-turbo进行大小压缩,并且压缩后不失真,清晰度和原图基本无差别。

背景

libjpeg开源的JPEG图像库,它使用非常广泛,Android也依赖libjpeg来压缩图片,但是Android不是直接使用libjpeg,而是基于一个叫Skia的开源项目来作为的图像处理引擎,Skialibjpeg进行了良好的封装。libjpeg在压缩图像时,有一个参数叫optimize_coding,这个参数的设置直接影响图片的质量和大小。

如果设置optimize_coding为true,将会使得压缩图像过程中基于图像数据计算哈弗曼表(关于图片压缩中的哈弗曼表,可以百度下查阅相关资料),由于这个计算会显著消耗空间和时间,默认值被设置为false。采用默认哈夫曼表进行计算。optimize_codingSkia中默认值也是false。

随着时间的推移现在 Android 手机性能越来越好,Google 在Android 7.0后已经设置为true了。如下代码可以看到。

源码地址:
http://androidos.net.cn/androidossearch?query=SkImageDecoder_libjpeg.cpp

>=android 7.0 后的源码
// ...省略其它代码
// Tells libjpeg-turbo to compute optimal Huffman coding tables
// for the image.  This improves compression at the cost of
// slower encode performance.
cinfo.optimize_coding = TRUE;
jpeg_set_quality(&cinfo, quality, TRUE /* limit to baseline-JPEG values */);
// ...省略其它代码

也就是说在Android 7.0前中无论你怎么压缩(尺寸压缩,质量压缩,Matrix 矩阵变换)它都会导致图片质量变差,而且在app应用层是无法修改的。但是我们可以自己编译libjpeg来设置这个参数为true,既然Android 7.0 后已经是optimize_coding = TRUE;还有必要自己编译libjpeg来设置这个参数为true 吗? 不过没关系,我们就拿这个来学习jni也是挺好的。这个库也支持解压缩(有兴趣的可以研究下),接下来我们看下如何实现压缩。

在有ugc功能的app中,拍照上传图片的时候基本都会进行压缩。
但是我看了下快手,微信,抖音的apk里。 快手里有用这个压缩库,微信抖音好像并使用libjpeg,也可能是改了so的名字,也可能出于7.0 后已经为true的考虑没必要处理7.0之前的版本了,也可能是用的Android 系统提供的API,也可能直接上传的原图云端进行的压缩。

压缩效果对比

下图是一张2.4MB的原始图片采用30%的压缩后是632KB,4倍还是可以的。清晰度对比如下,几乎看不出来差别,但是如果压缩到10%,图片有稍微的清晰度降低,真实项目可以权衡下。说明效果远比Android 内置的好。

(图片拍摄于2020-11-16号北京西北旺下班回家的公交站~,留下纪念,说不定哪天就不在北京了。)

压缩前后清晰度对比.png

libjpeg-turbo在Android环境下的编译

开源libjpeg-turbo源码

这个确实不好编译,有点坑。。。我编译的时候在网上也百度了下,大部分的文章都是几年前的,大部分在linux环境下编译的,提供了一些脚本,但都不是Android平台下的,也不是基于libjpeg-turbo最新的代码进行,最后尝试都编不过。哭唧唧,只能自己看文档了最后折腾ok。

编译步骤大概如下

  1. 下载libjpeg-turbo源码
  2. 编写脚本,结合libjpeg-turbo目录下BUILDING.md文件,使用Android ndk提供的cmake自带交叉编译工具链编译
  3. 跑脚本,最后生成Android下个平台的so和需要的头文件

备注,我用的是最新的ndk 21.1.6352462(最新)编译的,它已经不支持生成armeabi平台的so了,有点奇怪。

下面是完整的编译build.sh脚本,如果你要编译需要把build.sh放在与下载后的源码命令同级下执行sh build.sh
同时把脚本的CMAKE_PATHNDK_PATH改为自己电脑的路径即可。
build.sh 也放到了这个压缩demo的工程里了。

#编译参考了https://www.jianshu.com/p/20902ca448ae?utm_source=oschina-app

# lib-name
MY_LIBS_NAME=libjpeg-turbo
MY_SOURCE_DIR=$(pwd)/libjpeg-turbo
MY_BUILD_DIR=binary

CMAKE_PATH=/Users/guxiuzhong/Library/Android/sdk/cmake/3.10.2.4988404
export PATH=${CMAKE_PATH}/bin:$PATH
NDK_PATH=/Users/guxiuzhong/Library/Android/sdk/ndk/21.1.6352462


BUILD_PLATFORM=linux-x86_64
TOOLCHAIN_VERSION=4.9
ANDROID_VERSION=24

ANDROID_ARMV5_CFLAGS="-march=armv5te"
ANDROID_ARMV7_CFLAGS="-march=armv7-a -mfloat-abi=softfp -mfpu=neon"  # -mfpu=vfpv3-d16  -fexceptions -frtti
ANDROID_ARMV8_CFLAGS="-march=armv8-a "                   # -mfloat-abi=softfp -mfpu=neon -fexceptions -frtti
ANDROID_X86_CFLAGS="-march=i386 -mtune=intel -mssse3 -mfpmath=sse -m32"
ANDROID_X86_64_CFLAGS="-march=x86-64 -msse4.2 -mpopcnt -m64 -mtune=intel"

# params($1:arch,$2:arch_abi,$3:host,$4:compiler,$5:cflags,$6:processor)
build_bin() {

    echo "-------------------start build $1-------------------------"

    ANDROID_ARCH_ABI=$1    # armeabi armeabi-v7a x86 mips
    CFALGS="$2"
    
    PREFIX=$(pwd)/dist/${MY_LIBS_NAME}/${ANDROID_ARCH_ABI}/
    # build 中间件
    BUILD_DIR=./${MY_BUILD_DIR}/${MY_LIBS_NAME}/${ANDROID_ARCH_ABI}

    echo "path==>$PATH"
    echo "build_dir==>$BUILD_DIR"
    echo "ANDROID_ARCH_ABI==>$ANDROID_ARCH_ABI"
    echo "CFALGS==>$CFALGS"


    mkdir -p ${BUILD_DIR}
    cd ${BUILD_DIR}

    # -DCMAKE_MAKE_PROGRAM=${NDK_PATH}/prebuilt/${BUILD_PLATFORM}/bin/make \
    # -DCMAKE_ASM_COMPILER=${NDK_PATH}/prebuilt/${BUILD_PLATFORM}/bin/yasm \

    cmake -G"Unix Makefiles" \
      -DANDROID_ABI=${ANDROID_ARCH_ABI} \
      -DANDROID_PLATFORM=android-${ANDROID_VERSION} \
      -DCMAKE_BUILD_TYPE=Release \
      -DANDROID_NDK=${NDK_PATH} \
      -DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
      -DCMAKE_POSITION_INDEPENDENT_CODE=1 \
      -DCMAKE_INSTALL_PREFIX=${PREFIX} \
      -DANDROID_ARM_NEON=TRUE \
      -DANDROID_TOOLCHAIN=clang \
      -DANDROID_STL=c++_static \
      -DCMAKE_C_FLAGS="${CFALGS} -Os -Wall -pipe -fPIC" \
      -DCMAKE_CXX_FLAGS="${CFALGS} -Os -Wall -pipe -fPIC" \
      -DANDROID_CPP_FEATURES=rtti exceptions \
      -DWITH_JPEG8=1 \
      ${MY_SOURCE_DIR}

    make clean
    make
    make install

    cd ../../../

    echo "-------------------$1 build end-------------------------"
}

# build armeabi
build_bin armeabi "$ANDROID_ARMV5_CFLAGS"

#build armeabi-v7a
build_bin armeabi-v7a "$ANDROID_ARMV7_CFLAGS"

#build arm64-v8a
build_bin arm64-v8a "$ANDROID_ARMV8_CFLAGS"

#build x86
build_bin x86 "$ANDROID_X86_CFLAGS"

#build x86_64
build_bin x86_64 "$ANDROID_X86_64_CFLAGS"

编译结构&编译成功后会生成Android下个平台的so和需要的头文件 如下:

编译结构.png

压缩

编译后,把生成的so和头文件拷贝到Android工程中,同时修改CMakeLists.txt文件,指定头文件,查找so的路径,以及该jni工程生成的so需要链接的so:libjpeg.solibturbojpeg.so

怎么压缩呢? 其实很简单的,整体分如下几步。

  1. 获取图片Bitmap的像素。
  2. 取出每个像素的argb通道,alpha通道丢弃,把bitmap的rgb像素转为一维数组进行保存(格式是R,G,B,R,G,B,R,G,B,...)。
  3. 用libjpeg进行压缩。
  4. 释放资源

获取Bitmap的像素

这个在上一篇文章有介绍,这里就不多介绍了。

Android-JNI开发系列《九》实战-Bitmap处理实现底片灰度化黑白化暖冷色调等效果

获取每个像素取出ARGB通道

只要拿到了这个就可以对图片进行任何处理了(包含上篇文章对图片的特效处理)。libjpeg-turbo这个开源库也不例外。
因为我们把它压缩为jepg格式的图片,alpha通道是可以丢弃的。有一个特别注意的点就是:在jni层中,Bitmap像素点的值是ABGR,而不是ARGB,也就是说,高端到低端:A,B,G,R 。这个我们在上一篇文章也验证过。 因为libjpeg-turbo压缩的时候需要的格式是R,G,B,R,G,B,R,G,B,...也就是一维数组。 也就是把图片的二位像素转为一维数组,很简单,赋值然后指针++处理就行了。部分代码:

    int i = 0, j = 0;
    BYTE r, g, b;
    //存储RGB所有像素点
    BYTE *data = (BYTE *) malloc(w * h * 3);
    // 临时保存指向像素内存的首地址
    BYTE *tempData = data;
    uint32_t color;
    for (i = 0; i < h; i++) {
        for (j = 0; j < w; j++) {
            // 取出一个像素  去调了alpha,然后保存到data中,对应指针++
            color = *((uint32_t *) pixelsColor);
            // 在jni层中,Bitmap像素点的值是ABGR,而不是ARGB,也就是说,高端到低端:A,B,G,R
            b = ((color & 0x00FF0000) >> 16);
            g = ((color & 0x0000FF00) >> 8);
            r = ((color & 0x000000FF));
            // jpeg压缩需要的是rgb
            //  for example, R,G,B,R,G,B,R,G,B,... for 24-bit RGB color.
            *data = r;
            *(data + 1) = g;
            *(data + 2) = b;
            data += 3;
            pixelsColor += 4;
        }
    }

简单吧, BYTE就类似java的byte,typedef uint8_t BYTE; 别名,无符号8位。

用libjpeg进行压缩

这一步最关键,我们拿到了图片bitmap的原始的像素就可以做处理。怎么使用libjpeg-turbo这个开源库提供的压缩方法呢,其实你下载后在源码的目录下有一个example.txt文件,这里有很清晰的使用方法,还有详细的注释。

大概分为7步。

  1. 初始化压缩对象。jpeg_create_compress
  2. 设置压缩后的数据的输出形式jpeg_stdio_dest,比如输出到文件
  3. 设置压缩的参数jpeg_set_defaults。 这里最重要,也就是我们需要把optimize_coding设置为true。 因为默认是false。
  4. 开始压缩jpeg_start_compress
  5. 按行循环写入。jpeg_write_scanlines
  6. 结束压缩。jpeg_finish_compress
  7. 释放压缩对象。jpeg_destroy_compress

代码中有比较详细的注释。按照它提供的example.txt文件中的示例写就行,压缩方法如下:

int write_JPEG_file(BYTE *data, int w, int h, int quality,
                    const char *outFilename, jboolean optimize) {
    //jpeg的结构体,保存的比如宽、高、位深、图片格式等信息
    struct jpeg_compress_struct cinfo;

    /* Step 1: allocate and initialize JPEG compression object */

    /* We set up the normal JPEG error routines, then override error_exit. */
    struct my_error_mgr jem;
    cinfo.err = jpeg_std_error(&jem.pub);
    jem.pub.error_exit = my_error_exit;
    /* Establish the setjmp return context for my_error_exit to use. */
    if (setjmp(jem.setjmp_buffer)) {
        /* If we get here, the JPEG code has signaled an error.
         and return.
         */
        return -1;
    }
    jpeg_create_compress(&cinfo);

    /* Step 2: specify data destination (eg, a file) */

    FILE *outfile = fopen(outFilename, "wb");
    if (outfile == nullptr) {
        LOGE("can't open %s", outFilename);
        return -1;
    }
    jpeg_stdio_dest(&cinfo, outfile);

    /* Step 3: set parameters for compression */

    cinfo.image_width = w;      /* image width and height, in pixels */
    cinfo.image_height = h;
    cinfo.input_components = 3;           /* # of color components per pixel */
    cinfo.in_color_space = JCS_RGB;       /* colorspace of input image */
    //是否采用哈弗曼表数据计算
    cinfo.optimize_coding = optimize;
    //设置哈夫曼编码,TRUE=arithmetic coding, FALSE=Huffman
    if (optimize) {
        cinfo.arith_code = false;
    } else {
        cinfo.arith_code = true;
    }
    // 其它参数 全部设置默认参数
    jpeg_set_defaults(&cinfo);
    //设置质量
    jpeg_set_quality(&cinfo, quality, TRUE /* limit to baseline-JPEG values */);


    /* Step 4: Start compressor */

    jpeg_start_compress(&cinfo, TRUE);


    /* Step 5: while (scan lines remain to be written) */
    /*           jpeg_write_scanlines(...); */

    JSAMPROW row_pointer[1];
    int row_stride;
    //一行的RGB数量
    row_stride = cinfo.image_width * 3; /* JSAMPLEs per row in image_buffer */
    //一行一行遍历
    while (cinfo.next_scanline < cinfo.image_height) {
        //得到一行的首地址
        row_pointer[0] = &data[cinfo.next_scanline * row_stride];
        //此方法会将jcs.next_scanline加1
        jpeg_write_scanlines(&cinfo, row_pointer, 1);//row_pointer就是一行的首地址,1:写入的行数
    }
    /* Step 6: Finish compression */
    jpeg_finish_compress(&cinfo);
    /* After finish_compress, we can close the output file. */
    fclose(outfile);
    outfile = nullptr;

    /* Step 7: release JPEG compression object */

    /* This is an important step since it will release a good deal of memory. */
    jpeg_destroy_compress(&cinfo);

    /* And we're done! */
    return 0;
}

我这里是直接同步压缩的,压缩是个耗时的操作(上面的效果图大概360毫秒左右),你可以在jni层中开启线程,然后压缩成功/失败通过回调到java层中,当然也可以在java层开启线程,都差不多。这里demo就直接int返回了,成功0,失败-1.

压缩失败的处理,在压缩的步骤1中进行设置,在jpeg_compress_structerr字段,err字段是一个jpeg_error_mgr的结构体,该结构体描述压缩失败的信息,比如错误信息,错误码,有几个函数指针,比如error_exitemit_messageoutput_message等。如果赋值的话当压缩失败的时候会回调你的方法。

释放资源

最后记得文件该closefclose,内存该freefree即可,jni中的也该释放的释放,jpeg的用完调用 jpeg_destroy_compress。避免内存泄漏问题。

最后上源码:

demo是读取sd卡的图片压缩后写到了sd卡里,记得添加读写sd卡的权限。

https://github.com/ta893115871/JNIBitmapCompress

备注
本文也是为实践jni,学习jpeg压缩。其中编译libjpeg参考了网上的libjpeg-turbo的编译,有一篇不错。
https://www.jianshu.com/p/20902ca448ae?utm_source=oschina-app

你可能感兴趣的:(Android-JNI开发系列《十》实践利用libjpeg-turbo完美压缩图片不失真)