Android NDK —— JPEG 压缩的优化

 

前言

若想将一 RGB 的像素值封装成 JPEG 输出, 主要需要经过如下几步

色彩空间的转化 -> 离散余弦变换DCT -> 数据量化(Quantization) -> 压缩编码

为了优化 Android 的 JPEG 压缩率, 这里利用 libjpeg-turbo 探究一下 不同压缩编码的性能表现, 文章主体如下

  • 编译 libjpeg-turbo
  • JPEG 压缩编码性能比较
  • Android JPEG 压缩的优化

一. 编译 libjpeg-turbo

操作系统

MacOS Mojave version 10.14.5

Libjpeg-turbo 版本

从 Github 上下载最新的源码即可
https://github.com/libjpeg-turbo/libjpeg-turbo

NDK 版本

NDK16

cmake 版本

➜  ~ cmake -version
cmake version 3.14.5

注释版本号

为了方便使用, 我们需要先注释版本号

  • 打开 libjpeg-turbo/sharedLibs/CMakeList.txt, 将设置版本号的位置注释
    #set_target_properties(jpeg PROPERTIES SOVERSION ${SO_MAJOR_VERSION}
    #  VERSION ${SO_MAJOR_VERSION}.${SO_AGE}.${SO_MINOR_VERSION})
    

脚本编写

Android 端脚本编写指南在 libjpeg-turbo 库中的 BUILDING.md 中有说明

Building libjpeg-turbo for Android
----------------------------------

Building libjpeg-turbo for Android platforms requires v13b or later of the
[Android NDK](https://developer.android.com/tools/sdk/ndk).


### ARMv7 (32-bit)

The following is a general recipe script that can be modified for your specific
needs.

    # Set these variables to suit your needs
    NDK_PATH={full path to the NDK directory-- for example,
      /opt/android/android-ndk-r16b}
    TOOLCHAIN={"gcc" or "clang"-- "gcc" must be used with NDK r16b and earlier,
      and "clang" must be used with NDK r17c and later}
    ANDROID_VERSION={the minimum version of Android to support-- for example,
      "16", "19", etc.}

    cd {build_directory}
    cmake -G"Unix Makefiles" \
      -DANDROID_ABI=armeabi-v7a \
      -DANDROID_ARM_MODE=arm \
      -DANDROID_PLATFORM=android-${ANDROID_VERSION} \
      -DANDROID_TOOLCHAIN=${TOOLCHAIN} \
      -DCMAKE_ASM_FLAGS="--target=arm-linux-androideabi${ANDROID_VERSION}" \
      -DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
      [additional CMake flags] {source_directory}
    make
......

我们按照它的要求, 进行 shell 脚本的编写即可, 编写后的shell 脚本如下

# 定义变量
ARCH=arm
ANDROID_ARCH_ABI=armeabi-v7a
ANDROID_VERSION=19
NDK_PATH=/Users/sharrychoo/Library/Android/ndk/android-ndk-r16b
PREFIX=`pwd`/android/${ARCH}/${CPU}
CFALGS="-march=armv7-a -mfloat-abi=softfp -mfpu=neon"

# 使用 cmake 命令生成 Makefile
cmake -G"Unix Makefiles" \
	-DANDROID_ABI=${ANDROID_ARCH_ABI} \
	-DANDROID_ARM_MODE=${ARCH} \
	-DANDROID_PLATFORM=android-${ANDROID_VERSION} \
	-DANDROID_TOOLCHAIN=clang \
	-DCMAKE_TOOLCHAIN_FILE=${NDK_PATH}/build/cmake/android.toolchain.cmake \
	-DCMAKE_BUILD_TYPE=Release \
	-DANDROID_NDK=${NDK_PATH} \
	-DCMAKE_POSITION_INDEPENDENT_CODE=1 \
	-DCMAKE_INSTALL_PREFIX=${PREFIX} \
	-DANDROID_ARM_NEON=TRUE \
	-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 \
	..

# 生成 so 库
make clean
make
make install

如此便可以获得 libjpeg 的 so 库, 将其和头文件拷贝到工程中即可使用

关于 libjpeg-turbo 的使用, 这里就不赘述了, 其官方提供好的 sample 如下

得到了 libjpeg-turbo 的 so, 接下来便可以探究 JPEG 压缩编码的表现了

二. JPEG 压缩编码性能比较

libjpeg-turbo 支持的编码算法如下

  • 霍夫曼编码
    • 优化的霍夫曼
    • 未优化的霍夫曼
  • 算术编码

我们将压缩质量设置 50, 看看他们在空间和时间两个个维度上的表现

空间与时间

Origin file length is 6607kb
// 未优化的霍夫曼编码
SCompressor compressed file length is 808kb, cost time is 731ms
// 优化的霍夫曼编码
SCompressor compressed file length is 729kb, cost time is 783ms
// 算术编码
SCompressor compressed file length is 653kb, cost time is 809ms
  • 压缩率上
    • 优化的霍夫曼编码比未优化的高 10%
    • 算术编码比优化的霍夫曼编码高 10%
  • 时间消耗上, 优化的霍夫曼编码稍慢

问题探究

为什么算术编码比优化的霍夫曼编码压缩率高?
  • 算术编码若想完成解码需求, 需要保存原始数据的编码表和长度大小, 以及编码后的一个浮点数值
    • 编码表的大小与数据的重复度成正相关
  • 霍夫曼编码若想完成解码需求, 需要保存霍夫曼编码树和编码后的整个数据
    • 霍夫曼树的大小与数据的重复度成正相关

算术编码从实现上就比霍夫曼更加优秀, 它编码后的结果为一个浮点数值, 而霍夫曼编码则需要保存编码后的整个数据, 这正是算术编码比霍夫曼编码的压缩率高 10% 的原因

为什么优化的霍夫曼编码比未优化的压缩率高?

优化的霍夫曼编码, 即根据源数据的计算一个霍夫曼树, 并且按照这个霍夫曼树进行霍夫曼编码, 而未优化的霍夫曼算法是直接使用 Libjpeg 提供的默认的霍夫曼树进行编码

  • 优势: 省去了构建霍夫曼树和霍夫曼树映射表的过程
  • 劣势: 默认的霍夫曼树为了保证通用性, 势必要考虑所有的数值(假设为 0-255), 因此这个霍夫曼树要比根据数据源构建的要大一些

了解优化的霍夫曼编码压缩率更高的原因, 其耗时更久的疑惑也同样得以解决了

三. Android JPEG 压缩的优化

源码实现

Android 的 2D 处理框架为 Skia, 在 JPEG 图像压缩上它链接了 libjpeg-turbo, 不同的是 Google 在不同的 Android 版本上的使用方式有所不同

优化方案

在 Android 7.0 以下的设备, 其 skia 的压缩实现是使用未优化的霍夫曼编码

  • 使用优化的霍夫曼编码, 提升 10% 的压缩率
  • 使用算术编码来, 提升 20% 的压缩率
    • 使用算术编码生成的 jpeg 可能存在兼容性问题
  • 关闭快速离散余弦算法, 解决精度丢失的问题

在 Android 7.0 以上的设备, 其 skia 的压缩实现为使用优化的霍夫曼编码, 可以采用以下的方式优化

  • 通过使用算术编码, 来提升 10% 的压缩率(使用算术编码生成的 jpeg 可能存在兼容性问题)

代码流程如下所示

// 在 Android 7.0 以上并且未开启算术编码, 使用 skia 实现
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !request.isArithmeticCoding) {
    skiaCompress(request, downsampledBitmap, outputFile);
}
// 在 Android 7.0 以下, 或开启了算术编码时使用我们自己的 libjpeg-turbo 实现
else {
    libjpegTurboCompress(request, downsampledBitmap, outputFile);
}

总结

通过本次的学习与实践, 很好的利用了 libjpeg-turbo 解决了 Android JPEG 压缩率低的问题, 现如今手机的性能日益强劲, 采用了时间换空间的思路, 使用毫秒级的时间差异去换取更好的压缩率, 能够加快图片在网络上的传输, 个人认为还是非常值得的

参考文献