因为论文太贵了，所以我照着论文标题和摘要尝试复现了一篇论文。上一篇文章讨论了DFT隐写，这种隐写方法能够抵抗各种攻击，但总体来说那更像是盲水印，用于标记而非传递信息。这一篇文章本来想写DCT，但后来我发现单纯的DWT和DCT隐写都是在频域上做手脚，并且效果都不是特别好。后来我看到一篇论文讨论DWT-DCT-SVD隐写，但很遗憾，这篇论文收费的，我在sci-hub上也没有找到，所以就照着论文标题和摘要，问了问ChatGPT，然后基于我和他的解读写了这篇文章。这篇文章并没有前几篇写的那么详尽，因为我对这之中提及的概念并不十分熟练，只是现学现卖。同时这种隐写方法的表现也没有那么好，或者说需要大量的尝试才能发挥出最佳水平。因此这篇文章描述的流程可能和论文里完全不一样，也可能是错的，也可能我漏掉了一些细节，总之我不太想在这种不确定（不清晰）的路径上花费太多精力去打磨。所以这篇文章可能在质量上不如前两篇。论隐写容量还得是LSB啊参考论文这里参考的论文是DWT-DCT-SVD based watermarking。摘要如下：Some works are reported in the frequency domain watermarking using Single Value Decomposition (SVD). The two most commonly used methods are based on DCT-SVD and DWT-SVD. The commonly present disadvantages in traditional watermarking techniques such as inability to withstand attacks are absent in SVD based algorithms. They offer a robust method of watermarking with minimum or no distortion. DCT based watermarking techniques offer compression while DWT based compression offer scalability. Thus all the three desirable properties can be utilized to create a new robust watermarking technique. In this paper, we propose a method of non-blind transform domain watermarking based on DWT-DCT-SVD. The DCT coefficients of the DWT coefficients are used to embed the watermarking information. This method of watermarking is found to be robust and the visual watermark is recoverable without only reasonable amount of distortion even in the case of attacks. Thus the method can be used to embed copyright information in the form of a visual watermark or simple text.隐写方案我拿着论文摘要去问ChatGPT，可能是因为我没开会员，所以ChatGPT也没说出来个所以然。虽然是2008年的论文，可能因为没有公开访问，所以ChatGPT的训练资料中没有相关的内容，总之帮助不大。从论文摘要来看，首先需要将图片应用DWT，然后对DWT结果再进行DCT，然后对DCT结果应用SVD，最后在SVD的S矩阵里进行隐写。DWT是离散小波变换；DCT是离散余弦变换，SVD是线性代数中的奇异值分解。话是这么说，在写DFT隐写的时候我就注意到了，除非有诸如FFT这种经过优化的快速算法，否则按照原始的公式定义去处理大图片会非常慢。但是就我在网络上搜索的结果来看，好像DWT和DCT都没有什么特别好用的快速算法，并且我期待这种隐写结果能够抵御JPEG压缩，所以我认为最好的办法就是从JPEG压缩中找灵感。JPEG压缩JPEG压缩是利用人眼对高频信息和色彩不敏感的原理，丢弃图像中不易被人眼辨识的信息，从而达到减少文件大小的效果。总地来说，JPEG压缩的流程如下：将图像从RGB空间转换成YUV空间，其中Y（明度）完全保留，U和V进行下采样（即2x2的格子只取左上角的值，相当于横竖各砍掉一半）以8x8像素划分成区块（Y通道是8x8，U和V对应是4x4），对每个区块计算DCT。DCT能够将能量聚集到矩阵的上三角部分（左上是低频，右下是高频）对DCT运算结果进行量化（取整）。根据量化表对不同频率的值进行整除。例如低频量化值是2，那么相对保留的细节就多一些（可取的值就是2的倍数），高频量化值相对高，例如99，则细节就少（可取的值只有99的倍数）。经过量化后能够做到下三角部分（高频）几乎都为0对量化结果进行编码这个过程中的主要信息损失就在量化部分。DCT也有损失，但只是损失了小数点，而量化则是以倍数进行损失的。例如量化值是2，那么5就会被量化成4；如果量化值是99，那么0到98都会被量化成0，而99到197全都会被量化成1，这种损失是非常大的。此外JPEG压缩是以8x8像素为单位独立压缩的，所以我们也可以把图片分成8x8的像素块进行处理。DWT离散小波变换是小波变换的离散形式（这不是废话吗）。什么是小波变换呢？本质上它是一组滤波器。在分解的时候，把一个信号输入到低通滤波器和高通滤波器，分别得到这个信号的低频分量和高频分量。但是这种分量并不是完全分离的，例如在f/2附近，低频分量可能会包含一些这个频率，而高频分量也会包含一些这个频率。对于这两个分量来说，因为频率被减半了，因此根据采样定理，这些分量可以被下采样（subsampled by 2）而不会丢失信息。在重组的时候，我们将对应的高低频分量输入到对应的滤波器中，然后通过插值还原出原始信号。至于为什么采样定理说可以通过下采样丢弃样本而不丢失信息，以及为什么通过插值就能还原出原始信号，我真没学过信号处理，我真的不知道。就上面这点还是我前几周刚学的呢。但是眼尖的读者可能会发现一些问题：你刚才说的什么信号啊滤波器啊，听起来都是一维的，图片是二维，那怎么办呢？说实话，我也在这个问题上反复了好久，看了好多公式和代码，就是没能理解为什么要那样处理。最后还是维基百科一个图片解决了我的疑惑。因为我懒得下载图片了，这里口述一下：图片作为原始信号，如果我们以行为单位，那么每行就是一个一维信号。这时将这个一维信号进行DWT，得到的低频是原始行信号的近似，而高频则是水平方向的突变（竖直边）。我们将这个低频信号记作L，高频信号记作H。这时L和H是等高半宽，因为按行（沿水平方向）滤波使得宽度减半，而高度没有变。我们可以很自然地将L放在左边，H放在右边，这样我们就得到了和原来大小一样的矩阵了。这个时候再按列看，对于L来说，将每一列作为一维信号进行DWT，得到了LL（低频）和LH（高频）。其中LL经过了行和列两个方向的低通滤波，因此留下的是原始图片的低频信息，这个矩阵也被称作近似矩阵。而LH经过按列的高通滤波，留下的是原始图像的竖直方向的突变，也就是水平边。类似地，如果对H也进行按列的DWT，会得到HL和HH。其中HL是对竖直边H的近似，因此留下的还是竖直边的特征。对于HH由于是对竖直边H进行竖直方向的滤波，留下的是水平方向的边，而原始信号H已经是竖直边了，综合来看HH矩阵保留的是原始图片的斜边特征。按列滤波得到的xL和xH可以上下排列，因此我们得到了左上角为LL（原始图片的低频近似），左下角为LH（原始图片的水平边），右上角为HL（原始图片的垂直边）和右下角的HH（原始图片的斜边）。考虑到JPEG压缩算法倾向于保留低频信号、丢弃高频信号，很明显，我们要对LL矩阵动手脚。因为在水平和竖直方向都进行了DWT，因此LL矩阵的长和宽都是原始图片的一半。也正因如此，LL矩阵的4x4像素块就对应了原始图片的8x8像素块。DCT离散余弦变换是余弦变换的离散形式（还是废话）。所谓余弦变换，其实就是用余弦来表示原始信号。听起来耳熟不？想想看之前的傅里叶变换，它使用复数表示一个频率的正弦分量（虚部）和余弦分量（实部）。如果简单地说，余弦变换其实就是只保留实部的傅里叶变换。但是从公式上来看，你会发现去掉虚部的傅里叶变换和JPEG压缩使用的DCT还不太一样，主要在于系数。JPEG使用的离散余弦变换得到的结果是正交的。你要问为什么要正交才行，这我也不知道。但从经验来说，通过某种努力得到的一种特殊性总能带来一些好处。这里我们使用原始的Orthogonal DCT-II和Orthogonal DCT-III，前者作为DCT，后者作为IDCT。同样地，这是一维变换，但和傅里叶变换类似，DCT也具有分离性，也就是说，我们可以先按行计算DCT，然后将计算结果按列再计算DCT。此外，由于我们只是在4x4的像素块上计算DCT，因此不使用快速算法也能很快地完成运算（使用并行计算）。当然，使用快速算法固然更好一些。但是只要性能还说得过去，就没有必要优化。通过对DWT结果的LL矩阵进行DCT运算，我们能够将近似矩阵中的能量分离。虽然LL矩阵本身已经是低频信号了，但是其展现形式仍然是原始图像的近似。通过DCT，我们能够将4x4像素块中的能量以余弦为模板分解。在分解结果中，左上角是低频，右下角是高频。这种位置上的固定能够给后续操作带来很大的便利——我们显然想要在低频中动手脚，因为这是最容易被JPEG压缩算法保留的。SVD奇异值分解是一个比较陌生的概念。虽然我很喜欢教我线性代数的老师（北方工业大学的钱盛老师），但很遗憾，因为学完了一直没怎么用到相关的知识，我现在已经完全忘光了。在开始说奇异值分解之前，我想用特征值来类比一下：方阵A表示一种空间变换，v是向量，$Av=\lambda v$翻译成人话就是向量v经过一种变换A，得到的结果和这个向量经过缩放$\lambda$倍得到的结果相同，在这种情况下，将v视为特征向量，在这个特征向量下$\lambda$就是A的特征值。直观地来说，就是对于一个特征向量，空间变换A和线性缩放$\lambda$的作用效果是一样的。这里$\lambda$描述的是A的一种特性或特征，因此$\lambda$是A的特征值。说到这里，我记得我肯定是学过求特征值，但现在我真是一步都不记得了。如果把特征值的定义改写一下：$A = v \lambda v^{T}$，你会发现这个和奇异值分解就很像了：$A = \mu \Sigma \sigma^T$。其中$\mu$和$\sigma$是两组正交单位向量，而$\Sigma$是对角阵，表示奇异值。和特征值的缩放不同，奇异值分解包含了旋转和缩放，其中奇异值就代表了缩放，而余下的两组基则代表了旋转。（可能还有投影，但是考虑到A是一个方阵，所以因该没有投影的事儿）通过对DCT系数进行奇异值分解，我们能够更进一步地使用奇异值来描述4x4像素块的频率信息。基于能量（频率）分解的角度，我们还可以这样理解奇异值分解：对于$A = U \Sigma V^T$，矩阵U和V的列向量表示一种模式，而$\Sigma$矩阵则描述了这两种模式如何叠加。奇异值矩阵的一些特点使得我们在隐写式更好处理：奇异值按照非递增顺序排列，即从左上角到右下角的方向奇异值是非负的实数，表示矩阵在每个模式或方向上的能量强度。较大的奇异值对应了矩阵中重要的能量模式，而较小的奇异值对应了较不重要的能量模式基于这两点，再考虑DCT结果近似是一个上三角阵，因此通过奇异值分解后，奇异值矩阵左上角的元素就是值最大的低频信息，也是这个4x4像素块中占主导地位的信息。通过修改这个元素，我们能够将信息隐写到图像的主要信息中，保证了隐写结果能够抵抗JPEG压缩。编码虽然我们理清了计算思路，但还有一个问题：如何将bit编码到实数范围的奇异值中？在上一篇DFT隐写中，我才用了取0和置1的方法——bit0对应频率强度为0；bit1对应频率强度大于一个给定的阈值。这是因为DFT计算结果的差距比较大，且不论正负，在绝对值上就从零点几跨越到了好几万。但这次不一样了——由于DCT和SVD运算只发生在4x4的像素块上，因此元素之间的差距不太大——最大不超过几千。因此我是用余数的方式来编码信息。考虑LSB编码，虽然是直接操作了bit，但换一种思路想，可以认为LSB是取2的余数，如果余数为0，那就是bit0，如果余数为1，那就是bit1。对于奇异值是小数的情况，我们可以选定一个值d，如果是bit 0，那么就让奇异值变成(floor(s / d) + 0.25) * d，这里考虑到SVD、DCT和DWT运算都是小数，计算结果可能会因为精度损失而上下浮动，因此加上0.25让它保证奇异值对d的余数不超过d的一半，同时也不会因为精度损失缩小被反转为bit1 （例如经过取整后应该是20，但经过计算后变成了19.9，原本20对5的余数是0，因此是bit0，但19.9对5的余数就是4.9，变成了bit1）；如果是bit1，则使用(floor(s / d) + 0.75) * d。在解码时可以直接使用(s % d) / d，如果值大于0.5就可以认定为bit 1，如果值小于0.5就认定为bit 0。当然，如果想让阈值更加灵活，可以使用一维K平均算法找到bit 0和bit 1的中心值，一次来判断是bit 0还是bit 1。代码实现代码实现的部分只挑主要的说，完整代码可以移步GitHub仓库。DWT我在Java上没找到什么可靠的DWT计算库。有个JWave，但是它的DWT只支持2次幂大小的输入，而我想要支持任意大小的输入。所以我参考了WaveLib，把C代码用Kotlin重写了。重写的过程肯定不算愉快，但我发现遵循如下技巧能够给自己省不少心：首先不要想C代码是做什么的，原封不动的把代码用Kotlin改写，保证调用起来的效果是一致的。使用调试来确保变量的值是相符的不要试图“融入”语言的特性——人家C怎么写的，你就用别的语言怎么翻译这里我是用CLion作为IDE调试C代码，因为我对C不是很熟，所以CLion给我提供了很大的帮助，包括根据类型寻找定义，使用调试器打断点和查看变量的值。经过这样一番折腾，我得到了一个丑陋但是和C语言行为一致的代码。之后我便开始阅读代码，将C代码逐步改写成Kotlin代码，并且保证改写前后代码的行为一致。但最终我发现C语言和Java还是有本质的区别。我注意到C语言中处理二维数组可以像处理一维数组那样。但是在Java中不行——Java中的数组索引只能是Int，这就意味着Java的数组是受到逻辑限制的。因此在Java中该用二维数组就得用二维数组，但是这套C代码是针对一维数组写的，所以在改写的时候花费了不少精力。在C中，通过控制读取数组的步长可以控制DWT是按行（步长为1）还是按列（步长为宽度），在Kotlin中我是用Lambda来实现类似的功能——按行的地方就使用arr[y][it]来访问，按列的地方就是用arr[it][x]来访问。类似地，在C代码中作者复用了数组来存储结果，在理解DWT变换之后，我选择每次计算都产生一个新数组——我都用Java了，我还在乎那点内存和性能吗？最总我还是成功地把对称填充的DWT2D移植到了Kotlin中，并且使用Kotlin协程加速计算。由于DWT运算要求二次幂大小的输入，因此需要对原始信号进行填充。这里我选择对称填充。如果原始信号是x1...xn，那么填充后的信号就是...|x3|x2|x1|x1|x2|x3|...|xn|xn|xn-1|...DCTDCT相对好办，因为尺寸是固定的，所以我只需要按照维基百科上给出的公式计算就行了。其中我注意到一些值其实是可以缓存的，因此我设计了一个查找表。给定一个大小之后（比如4），提前计算出一些三角函数的常量，比起实时运算，很显然直接查表要快非常多。SVD之前我使用了Apache Common Math 3的傅里叶计算，我惊喜地发现它也有SVD，因此我就直接用这个库的了。组装在有了以上的基础之后，我们就可以把程序组装起来了。程序的流程大概如下：读入图片，从RGB转换成YUV，如果边长是奇数，则补0变成偶数转换成YUV时，Y通道需要减128使其范围对称，这是DCT的要求分别对YUV三个通道应用Haar DWT，取出LL矩阵，余下三个存起来针对三个LL矩阵，切分成4x4的像素块，不够的地方补0对这些像素块应用DCT和SVD隐写将修改后的S矩阵还原回DCT系数，然后进行IDCT将4x4像素块展开，还原回LL矩阵配合之前存起来的矩阵，使用IDWT还原出隐写后的通道将YUV转换成RGB，保存成图片在隐写时需要选定参数，但是这个参数会取决于图片本身的特性，甚至不同通道取用的d值都不一样，需要不断尝试。因此我将前四步抽取出来，一张图片计算出SVD之后，隐写时直接给出S矩阵的副本，然后进行后续操作。这样在程序中搜索d值的时候可以免去重复计算一样的东西：package info.skyblond.steganography.dwtdctsvd import info.skyblond.steganography.yuvToRGB import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import org.apache.commons.math3.linear.RealMatrix import org.apache.commons.math3.linear.SingularValueDecomposition import java.awt.image.BufferedImage class DWTDCTSVD( private val sourceImage: BufferedImage ) { private val yDecompositions: List<Decomposition> private val uDecompositions: List<Decomposition> private val vDecompositions: List<Decomposition> private val ySVD: Array<Array<SingularValueDecomposition>> private val uSVD: Array<Array<SingularValueDecomposition>> private val vSVD: Array<Array<SingularValueDecomposition>> private val llWidth: Int private val llHeight: Int init { // RGB -> YUV -> Haar DWT val (yDWT, uDWT, vDWT) = runBlocking { decomposeImageYUVHaarDWT(sourceImage) } val yLL = yDWT.let { yDecompositions = it.second; it.first } val uLL = uDWT.let { uDecompositions = it.second; it.first } val vLL = vDWT.let { vDecompositions = it.second; it.first } // yuv should share the same size llWidth = yLL.width llHeight = yLL.height // LL -> chunk to 4x4 val yChunks = yLL.chunked4x4() val uChunks = uLL.chunked4x4() val vChunks = vLL.chunked4x4() // Chunk -> DCT -> SVD ySVD = runBlocking { yChunks.applyDCTSVD() } uSVD = runBlocking { uChunks.applyDCTSVD() } vSVD = runBlocking { vChunks.applyDCTSVD() } } private suspend fun applyISVDIDCT( svdChannel: Array<Array<SingularValueDecomposition>>, modifiedS: Array<Array<RealMatrix>> ): Array<Array<Chunk>> = coroutineScope { svdChannel.mapIndexed { y, row -> val rowS = modifiedS[y] async { row.mapIndexed { x, svd -> async { val recovered = svd.u.multiply(rowS[x]).multiply(svd.vt) // copy to chunk and apply idct Chunk(Array(4) { y -> DoubleArray(4) { x -> recovered.getEntry(y, x) } }) .idct2d() } }.awaitAll().toTypedArray() } }.awaitAll().toTypedArray() } private fun copyMatrixS(): Triple<Array<Array<RealMatrix>>, Array<Array<RealMatrix>>, Array<Array<RealMatrix>>> { val yS = ySVD.let { Array(it.size) { y -> Array(it[y].size) { x -> it[y][x].s.copy() } } } val uS = uSVD.let { Array(it.size) { y -> Array(it[y].size) { x -> it[y][x].s.copy() } } } val vS = vSVD.let { Array(it.size) { y -> Array(it[y].size) { x -> it[y][x].s.copy() } } } return Triple(yS, uS, vS) } suspend fun steganography(action: (Array<Array<RealMatrix>>, Array<Array<RealMatrix>>, Array<Array<RealMatrix>>) -> Unit): BufferedImage = coroutineScope { // each time we create a copy of SVD matrix, thus we can reuse val (yS, uS, vS) = copyMatrixS() action(yS, uS, vS) // then iSVD -> iDCT -> Chunk val (yLLChanged, uLLChanged, vLLChanged) = listOf( async { applyISVDIDCT(ySVD, yS).flatten4x4(llWidth, llHeight) }, async { applyISVDIDCT(uSVD, uS).flatten4x4(llWidth, llHeight) }, async { applyISVDIDCT(vSVD, vS).flatten4x4(llWidth, llHeight) }, ).awaitAll() // LL -> YUV Channel val yIDWT = idwt2d(yLLChanged, yDecompositions, Wavelet.haar) val uIDWT = idwt2d(uLLChanged, uDecompositions, Wavelet.haar) val vIDWT = idwt2d(vLLChanged, vDecompositions, Wavelet.haar) val recoveredImage = BufferedImage(sourceImage.width, sourceImage.height, sourceImage.type) for (y in 0 until recoveredImage.height) { for (x in 0 until recoveredImage.width) { val cy = yIDWT[y][x] + 128 val cu = uIDWT[y][x] val cv = vIDWT[y][x] val color = yuvToRGB(cy, cu, cv) recoveredImage.setRGB(x, y, color.rgb) } } recoveredImage } }由于三个通道的d值并不具有相关性，所以可以单独搜索每个通道的d：package info.skyblond.steganography.dwtdctsvd import info.skyblond.steganography.writeJPG import info.skyblond.steganography.writePNG import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import org.apache.commons.math3.linear.RealMatrix import java.awt.Color import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO import kotlin.math.abs import kotlin.math.floor object Test { private val sourceImage = ImageIO.read(File("./pic/pexels-lukas-hartmann-1497306.jpg")) private val outputDir = File("./output/dwt-dct-svd").apply { mkdirs() } @JvmStatic fun main(args: Array<String>): Unit = runBlocking(Dispatchers.Default) { println("Mask: w:${maskImage.width} h:${maskImage.height}") println("Preparing SVD...") val obj = DWTDCTSVD(sourceImage) (20..150 step 2).map { d -> val encodedImage = searchSingleEncode(obj, d) { y, u, v -> v } async { val file = File(outputDir, "test_dY_${dY}_dU_${dU}_dV_$d.jpg") encodedImage.writeJPG(file, 0.7) searchSingleDecode( "jpg_0.7_dY_${dY}_dU_${dU}_dV_$d", ImageIO.read(file), d ) { y, u, v -> v } } }.awaitAll() } private suspend fun searchSingleEncode( obj: DWTDCTSVD, d: Int, selector: (Array<Array<RealMatrix>>, Array<Array<RealMatrix>>, Array<Array<RealMatrix>>) -> Array<Array<RealMatrix>> ): BufferedImage { println("Applying steganography... $d") val iter = mask.iterator() return obj.steganography { ySArr, uSArr, vSArr -> for (y in 0 until ySArr.size - 1) { for (x in 0 until ySArr[0].size - 1) { val bit = iter.next() selector(ySArr, uSArr, vSArr)[y][x].setBit(bit, d) } } } } private suspend fun searchSingleDecode( name: String, image: BufferedImage, d: Int, selector: (SubRegion, SubRegion, SubRegion) -> SubRegion ) { println("Decoding image... $d") val (yDWT, uDWT, vDWT) = decomposeImageYUVHaarDWT(image) val (yLL, _) = yDWT val (uLL, _) = uDWT val (vLL, _) = vDWT val selectedLL = selector(yLL, uLL, vLL) val selectedDCTSVDChunks = selectedLL.chunked4x4().applyDCTSVD() val yBitStream = buildList { for (y in 0 until selectedDCTSVDChunks.size - 1) { for (x in 0 until selectedDCTSVDChunks[0].size - 1) { val v = selectedDCTSVDChunks[y][x].s.readBit(d) add(v) } } } val data = kMeans1D(yBitStream.toDoubleArray()) val outputImage = BufferedImage(maskImage.width, data.size / maskImage.width, BufferedImage.TYPE_INT_ARGB) for (y in 0 until outputImage.height) { for (x in 0 until outputImage.width) { val color = if (data[y * outputImage.width + x]) Color.WHITE else Color.BLACK outputImage.setRGB(x, y, color.rgb) } } outputImage.writePNG(File(outputDir, "recovered_$name.png")) } /** * Encode: * (floor([RealMatrix].getEntry(i,i) / [d]) + f([bit])) * [d], * * The result is the SVD entry will be quantized by [d]. * If the bit is true, then the reminder will be bigger than 0.5d_i, * otherwise it's smaller than 0.5d_i. * */ private fun RealMatrix.setBit(bit: Boolean, d: Int) { val b = if (bit) 0.75 else 0.25 val s = this.getEntry(0, 0) val m = floor(s / d) + b this.setEntry(0, 0, m * d) } private fun RealMatrix.readBit(d: Int): Double { val s = this.getEntry(0, 0) return (s % d) / d } private fun kMeans1D(inputs: DoubleArray): BooleanArray { var threshold: Double val err = 1e-6 // center point of 0 and 1 val center = doubleArrayOf(inputs.min(), inputs.max()) while (true) { threshold = (center[0] + center[1]) / 2 val bits = BooleanArray(inputs.size) { inputs[it] >= threshold } // check the distance and correct center center[0] = inputs.filterIndexed { index, _ -> !bits[index] }.average() // the avg of all bit0's value center[1] = inputs.filterIndexed { index, _ -> bits[index] }.average() // the avg of all bit1's value val newThreshold = (center[0] + center[1]) / 2 if (abs(newThreshold - threshold) < err) { // update the threshold and exit threshold = newThreshold break } } return BooleanArray(inputs.size) { inputs[it] >= threshold } } private val maskImage = ImageIO.read(File("./pic/fft_mask/mask3.png")) private val maskData = BooleanArray(maskImage.height * maskImage.width) { index -> maskImage.getRGB( index % maskImage.width, index / maskImage.width ) == Color.WHITE.rgb } private val mask = object : Iterable<Boolean> { override fun iterator(): Iterator<Boolean> = object : Iterator<Boolean> { override fun hasNext(): Boolean = true private var d = maskData.iterator() override fun next(): Boolean { if (!d.hasNext()) d = maskData.iterator() return d.next() } } } }这里通过调整searchSingleEncode和searchSingleDecode的最后一个参数就可以选择搜索哪个通道。等搜索到了每个通道表现最佳的d值之后，便可以使用这个函数编解码： private suspend fun applySteganography( obj: DWTDCTSVD, dY: Int, dU: Int, dV: Int, ): BufferedImage { println("Applying steganography... $dY $dU $dV") val iter = mask.iterator() return obj.steganography { ySArr, uSArr, vSArr -> for (y in 0 until ySArr.size - 1) { for (x in 0 until ySArr[0].size - 1) { val bit = iter.next() uSArr[y][x].setBit(bit, dU) vSArr[y][x].setBit(bit, dV) ySArr[y][x].setBit(bit, dY) } } } } private suspend fun decode( name: String, image: BufferedImage, dY: Int, dU: Int, dV: Int, ) { println("Decoding image... $dY $dU $dV") val (yDWT, uDWT, vDWT) = decomposeImageYUVHaarDWT(image) val (yLL, _) = yDWT val (uLL, _) = uDWT val (vLL, _) = vDWT val yDCTSVDChunks = yLL.chunked4x4().applyDCTSVD() val uDCTSVDChunks = uLL.chunked4x4().applyDCTSVD() val vDCTSVDChunks = vLL.chunked4x4().applyDCTSVD() val yBitStream = buildList { for (y in 0 until yDCTSVDChunks.size - 1) { for (x in 0 until yDCTSVDChunks[0].size - 1) { val vu = uDCTSVDChunks[y][x].s.readBit(dU) val vv = vDCTSVDChunks[y][x].s.readBit(dV) val vy = yDCTSVDChunks[y][x].s.readBit(dY) add(vy + vu + vv) } } } val data = kMeans1D(yBitStream.toDoubleArray()) val outputImage = BufferedImage(maskImage.width, data.size / maskImage.width, BufferedImage.TYPE_INT_ARGB) for (y in 0 until outputImage.height) { for (x in 0 until outputImage.width) { val color = if (data[y * outputImage.width + x]) Color.WHITE else Color.BLACK outputImage.setRGB(x, y, color.rgb) } } outputImage.writePNG(File(outputDir, "recovered_$name.png")) }这里我同时在三个通道进行隐写，然后取平均值。当然你可以使用随机数发生器来决定在哪个通道隐写。不过需要注意的是这并不是一种稳定可靠的隐写方式，因此直接写入字节数据可能会导致数据不可读。隐写效果这是一张仅在V通道进行隐写的图片，图片本身经过JPEG压缩，质量大约在70%，这是根据JPEG压缩后的图片解码出来的结果：如果你自己看的话，你会发现放大之后会有比较明显的格子分界线，同时有些格子比其他格子红一点点：这是因为在隐写时，选用d本身就是一种量化。我们首先对奇异值进行整除，然后根据bit来决定是加上0.25还是0.75倍的d，这使得d值过大时会产生明显的量化（对于Y通道尤为明显），而d不够大时又没办法保证隐写数据的稳定性。以d=150在Y通道隐写为例：虽然隐写结果异常清晰，但可以看到隐写后的图片四周出现了明显的条带。在观察到奇异值的第一个元素带来的改动太大后，我尝试修改了第二个元素，但第二个元素太弱，没办法通过JPEG压缩算法，于是只好作罢。后记通过搜索不同的d值，我发现如果你知道你要寻找什么的话，那使用这种隐写方法几乎很难做到修改幅度肉眼不可见，同时还要隐写数据足够稳定。当然，谈及改进的话，我觉得首先可以改进的就是编码方式，很显然不同格子的特性都不一样，因此对于d的选择可以使用自适应算法，根据上下文的相关信息来决定d的取值。不过在设计上有很大一部分受限于无法使用原图。原图的存在会很容易地暴露隐写数据的存在，而不使用原图则要求所有解码用的数据都必须包含在图片里。我在一开头就说了，这篇文章唯一的依据就是一篇论文的摘要。除此之外全都是我和ChatGPT（主要是我，ChatGPT在冷门领域完全就是个废物，只会复读）填补出来的。经过反复尝试，我发现这种隐写方法并没有达成我的期望——它固然是能够抵抗JPEG压缩，但任何隐写算法只要能够留下足够强的标记，它们总能被JPEG压缩留下。历时两个月，我想至此关于隐写术就暂且告一段落了。-全文完-
比起隐写，我认为DFT更像是盲水印。在各种D*T中，DFT看起来应该是相对简单的一个。DCT我有个大概印象，但DWT我实在不知道是什么高端的玩意儿。总之，本文将讨论在DFT变换域隐写数据。什么是DFTDFT全称Discrete Fourier transform，即离散傅里叶变换。那什么是傅里叶变换呢？我不知道该怎么给你解释，但你可以参考这个视频。简单地说，傅里叶变换是一种拆解信号的过程。以音频信号为例，一段录音对于计算机来说就是麦克风在时间尺度上对气压进行采样，一段录音可以看作是气压在时间上的函数。可是对于人的听觉来说，气压并不是特别有效的信息，而气压的变化才是。气压在某个固定周期内重复波动，这近似就算是频率了。我们的耳朵能够感受到频率，但计算机只能收录气压的采样，那怎么才能让计算机分析出人能够感知的频率呢？傅里叶变换就能够做到这件事。对于一维信号（只有一个变量），傅里叶变换将信号拆解成一系列正弦或余弦信号的叠加，而这里的正弦或余弦信号就是纯粹的频率。换句话说，傅里叶变换允许你用一系列纯粹的频率来表示一段音频（例如三倍的440Hz加上五倍的320Hz）。类似地，离散傅里叶变换是作用在离散信号上的。在理想情况下，声音应该是气压在时间上的连续函数，但对于计算机来说，声音则是每隔一段时间对气压的采样，它并不连续。因此将傅里叶变换推广到这种离散信号上，就变成了离散傅里叶变换。这在计算方式上变了，但在原理上没有改变。对于图像这样的二维信号（两个变量，x、y），傅里叶变换同样将其拆解为频率，但这里的频率并不是人耳能听到的频率了，它指的是像素明暗的周期性变化，而这里的频率也具有两个参数，分别表示水平频率（x方向）和垂直频率（y方向）。如果一个频率是(5,0)，那么这个频率看起来应该是水平方向的明暗交替；而频率(0,5)则是竖直方向的明暗交替；而频率(5,5)则是从左上角到右下角方向的明暗交替。如果光看文字难以想象的话，我推荐观看这个视频。从DFT到FFT离散傅里叶变换的计算其实不难，并没有什么特别高神的东西：$$ F(x)=\sum_{n=0}^{N-1}e^{-i\frac{2\pi}{N}nx}f(x) $$其中F(x)是傅里叶变换，f(x)是原来的信号。如果是二维的话，根据二维傅里叶变换的分离性，可以分解为在两个方向上分别进行傅里叶变换：即先以行为单位，计算x方向的傅里叶变换，然后以列为单位，计算y方向的傅里叶变换：suspend fun ComplexChannel.fft2d(): ComplexChannel = coroutineScope { // result: [height, width] val rowTransformed = this@fft2d.indices.map { y -> // feed each horizontal line into fft async { FFT.fft(this@fft2d[y]) } }.awaitAll() // here we calculate based on columns // aka the output shape is [width, height] val columnTransformed = this@fft2d[0].indices.map { x -> // we need to collect each vertical line async { val columnData = Array(this@fft2d.size) { y -> rowTransformed[y][x] } FFT.fft(columnData) } }.awaitAll() // recover the structure back to [height, weight] Array(this@fft2d.size) { y -> Array(this@fft2d[0].size) { x -> columnTransformed[x][y] } } }但如果你仔细观察DCT的定义的话，你会发现这个算法其实并不高效。在处理$e^{-i}$那部分时，需要使用欧拉公式将其变换为三角函数，这对于计算机来说本身就是一个难事了，更加雪上加霜的是，计算傅里叶变换的一个点就要对整个输入空间遍历一次。我们先看按行计算的傅里叶变换，假设图片宽度是w，那么计算一个点的傅里叶变换就要遍历w次，而完整计算一行就需要遍历$w^2$次，这还是一行，如果图片的高为h，那么光是按行计算就要遍历$hw^2$次；接下来再考虑按列计算，类似地，要$wh^2$次遍历。当你看到复杂度里面带着平方的时候，大概就能想到它算起来不会很快。为了解决这个问题，人们想出了FFT，快速傅里叶变换。由于我不是很在行数学，因此我就不多说其中的原理了。这里我参考了一篇文章，文章中提供了C++实现的能处理任意长度输入的FFT算法。在Java中，Apache commons Math3这个库提供了FFT实现，但它要求输入大小必须是2的幂。我认为padding不够优雅，且在内存上不够高效，因此我仿照文章中提供的C++实现编写了一个Kotlin下的FFT算法。在变换域隐写的问题目前为止，我们解决了计算的问题，接下来我们得想办法在频域中隐写。与LSB不同，我们之所以能够在LSB中隐写数据，主要有以下依据：隐写的数据能够按照原样写入、读出，即写入是什么，读出还是什么隐写的数据对原图像没有太大的破坏而频域隐写则无法依赖这两条。傅里叶变换是浮点数运算，在修改频域后还需要使用对应的逆运算还原回图像，在这个过程中，由于浮点数精度受限，写入的数据并不保证会被原封不动的保存。此外，由于我们修改的是频域，因此任何一个点的修改都会影响到图片全局，尤其是人眼对低频信号更为敏感，因此隐写在低频部分会使得图像面目全非；可是如果隐写的频率太高，那么受限于采样定理（奈奎斯特定理），经过逆变换得到的图片可能难以保留我们写入的数据（例如图像的宽度为w，但隐写数据出现在超过w/2的频率以上，此时可能会出现采样定理所说的混叠和失真）。除了以上问题，傅里叶变换的结果也不太利于隐写：通过将变换结果输出成CSV（输出复数的模），我观察到对于较大的图片（例如边长在1000像素以上的），在边缘部分（低频）的变换结果通常比较大，有时会达到两三万的程度，而中心部分（高频）则通常较弱，只有几百甚至几十，如此之大的差距使得变换结果没办法被线性地展示出来，同时，如此宽阔的取值范围也给隐写带来了很大的难度。这里需要补充一点，我认为这一点隐含在了傅里叶变换中，但如果你没有那么深入理解这个变换的话，这一点补充或许能够解答你的疑惑。在傅里叶变换的计算中我们同时使用了正弦和余弦，对于给定频率，都会得到两个分量幅度，这也就是为什么傅里叶变换的结果是个复数。如果以余弦信号来表示原始信号，那么这个复数的模就是余弦频率的强度；而复数的辅角（argument）就代表了这个频率的初始相位。在图片处理中，频率可以看成是一个纹理，和一维类似，强度决定了这个纹理在图片中的占比，而相位则决定了这个纹理在图片中的初始位置。如果保留强度而调整相位（调整实部和虚部的比例），则相当于改变一个纹理的位置；而保持相位改变强度（等比例同时缩放实部和虚部）则相当于调整这个纹理的明暗。本文只修改纹理的明暗，不改变纹理的位置。此外，由于傅里叶变换的对称性，在修改时需要保持共轭，这一点会在后续展示代码的时候强调。至于为什么傅里叶变换会有对称性，为何这种对称性要求复数共轭，可以参考这篇知乎回答。对数可视化与盲水印隐写变换结果的跨度太大，那我们就把跨度缩小好了。对数正好可以用来缩小跨度：$$ L(v) = \log_{10}(1 + k \cdot v) $$式中，v是复数的模，k是一个可变的系数，用来调整增益，这样一来函数L的输出就变成了正实数，如果再将其输出裁剪到0到1之间，那就可以产生一副灰度图了。既然都有图了，不如我们把思路打开：如果频域隐写没办法忠实地还原我们写入的数据，那让数据能够容错就是了。此外，对于图片的旋转、裁剪等也会反映到频域上，因此我们需要一种能够在旋转、裁剪、缩放的情况下还能解码的编码方式。听起来是不是有点耳熟？这不就是二维码吗？DFT隐写常常作为盲水印使用，因为它的编码效率不高，但是能保证你隐写进去的东西足够健壮，不会因为平移、旋转、裁剪、遮挡等攻击而消失不见。如果我们将数据编码成二维码，然后将二维码作为图片数据嵌入到频域，这样无论具有怎样的失真、旋转、拉伸，只要还在二维码的容错范围内，我们就可以读出其中的数据。不过这种隐写方式有一个弊端：那就是不够隐蔽。在LSB隐写中，我们可以用加密、随机等方式将数据分布到不同的通道，这是因为我们的编码方式依赖绝对位置。而DFT的一个特征就是频域能够随着图片的旋转而旋转，即便图像经过剪裁，也能在一定程度上保留频域数据，这就意味着DFT隐写时，我们不能依赖绝对位置，否则我们隐写的数据就和LSB一样脆弱了。可是没办法依赖绝对位置，也就没办法用随机性隐藏数据，尽管我们可以对数据加密，但频率里一个工工整整的二维码，这多少有些可疑的离谱了。但是没办法，你获得了健壮性，就要牺牲隐蔽性。如果你要隐蔽性，那就只能牺牲健壮性。关于这种隐写的容量，那肯定是不如LSB，但经过测试，用这种方法传递ed25519的公钥还是没问题的。如果你的图片选用得当，我认为基本的文字传递还是可以做到的。黑白半幅傅里叶隐写综上所述，我们可以将黑白图片编码到频域中，而取决于我们使用的象限，我们可以：使用一二象限，将数据编码在频域的上半部分，而下半部分是对应的共轭使用二三象限，将数据编码在频域的左半部分，而右半部分是对应的共轭在开始写代码之前，老规矩，先写接口：interface Fourier { suspend fun encode( source: BufferedImage, redMask: BufferedImage? = null, greenMask: BufferedImage? = null, blueMask: BufferedImage? = null, ): BufferedImage suspend fun decode( input: BufferedImage, energyLogKList: List<Double> ): List<Triple<BufferedImage, BufferedImage, BufferedImage>> suspend fun decode( input: BufferedImage, energyLogK: Double ): Triple<BufferedImage, BufferedImage, BufferedImage> = decode(input, listOf(energyLogK))[0] } 这里规定了编解码的函数签名。注意到解码的时候需要提供一个energyLogKList，这个就是刚才提到的对数变换的K的取值列表。一般编码的时候我们是根据给定的K来计算的，但在可视化的时候可以一口气产生不同K值下的可视化结果。虽然我们可以直接返回傅里叶变换的结果，但我认为这部分属于是内部实现，我不希望使用者获知其中的具体细节。你可以观察到，无论是编码还是解码，其中设计的数据类型全都是Java标准库自带的，并没有涉及到其他的库（例如Apache commons Math3的Complex类）。前述两种编码，无论使用哪种方式，编码的区域是不变的。因此我们只需要让子类给我们画出一片水印覆盖的区域就可以在父类中做到对称修改频域了。abstract class BlackAndWhiteHalfFourier( private val targetAmp: Double ) : Fourier { protected abstract fun checkInputSize(width: Int, height: Int, mask: BufferedImage) protected abstract fun calculateBox( width: Int, height: Int, mask: BufferedImage ): Pair<Pair<Int, Int>, Pair<Int, Int>> private fun encodeChannel(channel: ComplexChannel, mask: BufferedImage) { val height = channel.size val width = channel[0].size checkInputSize(width, height, mask) val (p0, p1) = calculateBox(width, height, mask) val (x0, y0) = p0 val (x1, y1) = p1 // apply encoding for (y in 0 until mask.height) { for (x in 0 until mask.width) { val oldComplex = channel[x0 + x, y0 + y] val targetAbs = 9.0 / targetAmp val oldAbs = oldComplex.abs() val newComplex = when (mask.getRGB(x, y)) { Color.BLACK.rgb -> Complex.ZERO Color.WHITE.rgb -> if (oldAbs < targetAbs) oldComplex * (targetAbs / oldAbs) else oldComplex else -> continue } channel[x0 + x, y0 + y] = newComplex channel[x1 - x, y1 - y] = newComplex.conjugate() } } } private fun encodeChannelOrNull(channel: ComplexChannel, mask: BufferedImage?): ComplexChannel = channel.also { if (mask != null) encodeChannel(it, mask) } final override suspend fun encode( source: BufferedImage, redMask: BufferedImage?, greenMask: BufferedImage?, blueMask: BufferedImage? ): BufferedImage = coroutineScope { val fft = source.fft2d() val redChannel = async { encodeChannelOrNull(fft.first, redMask) } val greenChannel = async { encodeChannelOrNull(fft.second, greenMask) } val blueChannel = async { encodeChannelOrNull(fft.third, blueMask) } ComplexImage( redChannel.await(), greenChannel.await(), blueChannel.await() ).ifft2d().recoverToImage() } final override suspend fun decode( input: BufferedImage, energyLogKList: List<Double> ): List<Triple<BufferedImage, BufferedImage, BufferedImage>> = coroutineScope { val recoveredFFT = input.fft2d() energyLogKList.map { k -> val red = async { recoveredFFT.first.visualize { it.energyLog(k) } } val green = async { recoveredFFT.second.visualize { it.energyLog(k) } } val blue = async { recoveredFFT.third.visualize { it.energyLog(k) } } Triple(red.await(), green.await(), blue.await()) } } }这里省略了不少工具类。可以移步Github仓库查阅完整代码。这里我们按照RGB三个通道分别编码。为了加速计算，这里使用了Kotlin协程。编码时取决于对应通道的mask是否为null，如果为null则不编码，否则将mask编码到对应通道中。编码时让子类给出一个中心对称的区域，父类中只要遍历mask，对称地修改这个区域内的频域即可。解码时直接将对应的通道做傅里叶变换，然后可视化即可。对于子类，以使用一二象限的编码方式为例：class BlackAndWhiteHorizontalFourier(targetAmp: Double) : BlackAndWhiteHalfFourier(targetAmp) { override fun checkInputSize(width: Int, height: Int, mask: BufferedImage) { require(mask.height < height / 2) { "Mask too big" } } override fun calculateBox(width: Int, height: Int, mask: BufferedImage): Pair<Pair<Int, Int>, Pair<Int, Int>> { // top left val x0 = width / 2 - mask.width / 2 val y0 = height / 2 - mask.height // bottom right val x1 = width / 2 + mask.width / 2 val y1 = height / 2 + mask.height return (x0 to y0) to (x1 to y1) } }在某种程度上来说，calculateBox的计算结果也是共轭的。类似地，这是使用二三象限的编码方式：class BlackAndWhiteVerticalFourier(targetAmp: Double) : BlackAndWhiteHalfFourier(targetAmp) { override fun checkInputSize(width: Int, height: Int, mask: BufferedImage) { require(mask.width < width / 2) { "Mask too big" } } override fun calculateBox(width: Int, height: Int, mask: BufferedImage): Pair<Pair<Int, Int>, Pair<Int, Int>> { // top left val x0 = width / 2 - mask.width val y0 = height / 2 - mask.height / 2 // bottom right val x1 = width / 2 + mask.width val y1 = height / 2 + mask.height / 2 return (x0 to y0) to (x1 to y1) } }编码结果以下展示的全都是截图，因为原图太大了。这是编码之后的图片，肉眼察觉不出异常。这是解码得到的：可以看到这张图片并没有太多细节，所以中心部分的高频比较弱。但我们的Data Matrix中包含了白底来增强对比度，因此使用对应的app可以无压力的扫描。接下来我们试试看用于分析LSB隐写的方法。直方图上看不出明显的异常，因此这里就不放了，这里主要看位平面分析和随机色彩映射：左边是隐写后的图片，右边是原图。可以看到原图的白底部分在位平面上非常干净，而经过隐写后的位平面则充满噪点。随机色彩映射也能看到类似的问题。但和LSB隐写引入的噪点明显不同：右侧是LSB隐写的分析结果，可以看到DFT隐写带来的噪点看起来更柔和一些，例如树叶边缘看起来就像是在发光一样，而LSB隐写带来的噪音则相对生硬。当然，正如我们在上一篇文章所说，噪点不是问题，只要我们的图片有充分存在噪点的理由，那就没有异常。接下来我们看看DFT隐写的能力。前面说了半天什么拉伸啊旋转啊，说了一大堆，我们不如亲眼看看。当然了，看二维码旋转没有意思，我准备了一大段文字作为mask来隐写。这里依次对隐写结果做缩放（等比例和非等比例）、旋转和遮挡，值得一提的是这些处理都保持了原有的尺寸，这是因为缩小尺寸将导致DFT无法计算高频部分。首先是原图：上半部分是肉眼可见的图片，下半部分是DFT后得到的结果。可以看到文字能够相对清晰的被识别，而上方的图片也没有过于明显的变化。接下来我们看看等比缩放和平面位移：根据傅里叶变换的性质，无论图片如何位移，傅里叶变换都能提取出其中的频率信息，但是这里进行了等比缩放，可以看出，水平方向上因为隐写数据躲开了高频的位置，经过缩放后虽然叠在一起，但通过对称性能够从右侧补全数据。而垂直方向就没有那么幸运了：垂直方向的数据一部分分布在中心的高频位置上，经过压缩后高频信息被破坏，因此文字的结尾几行已经无法辨认了。接下来我们看看非等比缩放加上10度的旋转：因为旋转的原因，DFT的结果也跟着旋转了10度，由于这次缩放主要发生在水平方向，因此垂直方向的数据还能够被辨认。通过拼凑左侧和上侧的图案，除了最后一行之外，大部分文字还是可以阅读的。接下来看看部分数据丢失会怎样。这里对原图的四边进行了裁剪，并拉伸放大回原来的分辨率：看起来部分数据的丢失并不影响隐写，这是因为隐写发生在频域，而频域对图片的影响是均匀发生在所有地方的。可以看到隐写的文字依旧清晰可见。当然，光是裁剪还不够，我们直接在图片上涂鸦：如果是LSB编码，这种涂鸦基本上就会破坏掉相当一部分数据了。如果把涂鸦换成白噪声（这里没有演示），那对于LSB来说就是灭顶之灾了。但是这种简单的涂鸦在频域上属于低频，因此对隐写数据的影响有限。如果换成噪声的话，取决于噪声的频率，可能会影响隐写数据。最后我们来看看两个更加过分的操作。第一个是假如半径为2像素的高斯模糊：可以发现图片看起来稍有模糊，但高斯模糊平滑了像素，拉低了频率，使得我们的隐写数据完全被破坏了。最后我们看看JPEG压缩：这里使用了两种不同的压缩质量。上面是压缩质量8，在Photoshop里算是中等质量，下面是压缩质量12，在Photoshop里算是最佳。可以看到经过JPEG压缩后，虽然能够隐约看出隐写数据，但高频部分几乎完全不可读了。这里使用的mask其实有点不完美，因为图片比较宽，导致隐写的数据在水平方向上的频率比较低，而垂直方向则集中在相对高频的部分，这部分数据受到挤压、裁剪等动作后也会随之丢失。在实际编码中，应该多加测试，保证隐写的数据躲开易受破坏的高频，同时保证尽可能不影响图像（主要是低频）。结语本文尝试了DFT隐写。正如开篇所说，我认为DFT在隐写方面并不隐蔽，一个简单的傅里叶变换就足以让肉眼观察到可疑的隐写迹象。也正因为这个原因，DFT更多被用在盲水印追踪领域，这个领域和隐写不同：隐写旨在藏匿数据，首要指标是隐蔽性和较大的数据量；而盲水印则通常只是隐写少量能够表明版权归属的文字或图案，同时追求健壮性，即能够抵御各种图像处理手段，经过处理后仍然能解读出水印以供查证版权。这个系列的下一篇文章要是不出意外的话，将会写DCT（离散余弦变换）隐写。DCT在JPEG压缩中广泛使用，但我并不打算做成JPEG隐写，因为那主要是针对文件格式进行的（修改系数或量化表）。在搜索相关文献的过程中，我看到一种DCT-SVD的隐写方法，该方法对DCT系数进行奇异值分解（SVD），然后奇异值中藏匿数据。虽然我还不知道该怎么做，但是我十分好奇这个过程，也打算尝试一番。-全文完-
本文介绍如何使用LSB隐写将数据藏匿在图片中。通常来说，要保护你的数据，最简单的方法就是加密。现代加密算法在正确使用的前提下可以认为是非常安全，除非这地球上所有国家决定不惜一切代价就是要干爆你（话是这么说，如果真是到了这种地步，一颗核弹不比破解先进的加密算法来的划算？）。但加密算法有一个问题：他会让别人看出来这是加密的。这一点在大多数情况下没什么问题，但假如你的身家性命就寄托在这份数据上，那除了加密，你最好还要把它好好地藏起来。不止间谍、记者和人权活动家有这种需求，我认为作为一个文明社会的人类个体，每一个人都有正当藏匿数据的权利，每个人的隐私也都值得且应当受到保护，任何其他个人和组织（包括政府）都不应该侵犯这种权利。而为了保护我们的权利，相应的技术也孕育而生。这些技术可能会被滥用于犯罪活动，但我认为从整体而言，隐私权利被侵犯的后果要比一个技术被滥用于犯罪的后果要大得多——任何时候为了预防犯罪而侵犯个人权利都是不恰当的。如果你强烈不认同这一观点，那么这里不欢迎你，请你离开。总之，和加密不同，隐写术是一种帮助你将数据隐藏在另一种数据中的技术。本篇文章以图片隐写术为主题，即以图片为载体藏匿数据。这其中，LSB隐写是一门非常原始但有效的技术（当然，如果使用得当的话）。你可能看出来了，任何宣称安全可靠的东西都有一个前提：使用得当。具体来说，不同的加密算法和隐写术有各自的特性和风险。我不是隐写术和安全方面的专家，本文也没有经过同行评议，因为它压根儿就不是论文。如果你决定采用下文中介绍的技巧和方法藏匿数据，你需要自己评估并决定承担其中的风险。何为LSB？LSB的全称是Least Significant Bit，翻译过来是最低有效位。什么叫最低有效位呢？比如一个数字12345，那它的最低有效位就是个位（数字5），因为它的改变对于值的影响最小。而相对应地，万位（数字1）是最高有效位，即Most Significant Bit，MSB。在计算机中，所有数据都是以二进制形式表示的，因此LSB通常是指bit 0。由于LSB的变化对于值的影响很小，而现代表示图像的方法采用RGB三个通道来表示，每个通道有256个可选的值。对于肉眼来说，这个值增大一点或减小一点，几乎难以区分。不信？看看这张图：这张图的主要颜色是0b87f0，但其中我以0b87ef、0b86f0和0c86f0三种颜色在上面随手花了几笔，你能找到吗？这种细微变化对于计算机来说是很明显的数值变化（你可以把这张图下载下来，放到Photoshop里，使用魔棒工具在容差为0的设置下随便选一个点，你就能看到Photoshop找到的轮廓），可是对于人眼来说，即便是最精确的显示器，人眼也无法辨别出这种细微的差距。因此这就成为了一种藏匿数据的方法，并且这种方法能够藏匿大量数据。但对应的，这种方法也非常脆弱。如果隐写的数据使用恰当的方式编码，也许可以抵挡剪切、平移、拉伸和旋转；但如果攻击者使用压缩算法处理了图片，那无论怎么编码都没办法保留数据——图像压缩算法的目的就是丢弃肉眼不可见的细节，从而减少数据量，而LSB隐写就是依靠这些细节来藏匿数据的。因此LSB隐写只对位图有效，也就是我们常见的bmp和png文件。虽然在二进制层面上设计一个抗剪切、平移、拉伸和旋转的编码方式很难，但如果你把格局打开，把被编码的数据当作图像的话，你甚至可以把一个二维码的黑白像素平铺到一张图片中。只要被裁剪的部分还能拼凑出一个完整的二维码，你就可以还原其中的数据。但这样一来，编码的效率就大大减少了。如果你要在一个图片里藏匿二进制文件，那几乎没有什么特别健壮的编码方式。一些冗余校验算法可以纠正部分损失，但是拉伸和旋转大概率是招架不住的。LSB隐写说到这里，有灵性的读者应该已经能够猜到LSB如何工作的了：一张图片就是有像素构成的二维数组，而每一个像素由三或四个通道构成。如果是BMP，那就是RGB三个通道；如果是PNG，那就是ARGB四个通道，A表示透明度。虽然通道这个说法听起来很高端，但实际上就是告诉你一个像素有几个值。由于表示透明度的Alpha通道不常用，因此这里我们只考虑RGB三个通道。一般而言一个像素有一个4字节的整数表示：0xAARRGGBB，AA表示Alpha通道的值，通常是FF，也就是255；而RR（bit 16到23）代表红色的取值、GG（bit 8到15）代表绿色的取值，而BB（bit 0到7）表示蓝色的取值。我们可以挑一个通道进行隐写，也可以同时在三个通道隐写，增加可以隐写的数据量。假设我们的图片大小为768*768，那么一共就有589824个像素，每个像素隐写1bit信息，我们就可以隐写72KB的信息。如果我们在三个通道同时隐写，那就可以隐写216KB的数据，这大约相当于一张低质量的JPG图片，或者7万字的中文文本。代码准备在开始隐写之前，我想先介绍一些辅助类。首先是用来表示信息的MessageStream。由于在隐写的过程中我们通常是和bit打交道，因此我写了这个类来将被隐写的二进制数据转换成比特流：interface MessageStream { fun nextBit(): Boolean val payloadSize: Int val extraSize: Int }这里的nextBit就是以类似迭代器的模式不断便利每个字节的每一个位（从低到高）。而后面两个size可以先不用管，这是为了后面的变换准备的。由于目前我们还没有什么花哨的变换，我们就直接把数据转换成bit就好了：abstract class DelegatedMessageStream : MessageStream { protected abstract fun readByte(): Int protected abstract fun resetStream() private var currentByte: Int = 0x00 private var currentPosition: Int = Byte.SIZE_BITS private fun readNextByte() { currentByte = readByte() if (currentByte == -1) { resetStream() currentByte = readByte() } } final override fun nextBit(): Boolean { if (currentPosition >= Byte.SIZE_BITS) { readNextByte() currentPosition = 0 } return (currentByte shr (currentPosition++)) and 0x01 != 0 } }这个抽象类实现了一个通用的逻辑：将产生字节数据的任务委托给子类（但是从readByte()返回整形值来看，你们都知道我要委托给谁了吧），而在抽象类中专心处理把字节转换成比特的工作。这里设计让消息本身不断重复，避免攻击者在分析图片时突然发现一个模式的中断（消息结束）。基于这个抽象类，我们可以编写一个PlainMessageStream：class PlainMessageStream( message: ByteArray ) : DelegatedMessageStream() { override val payloadSize: Int = message.size override val extraSize: Int = 0 private var delegatedStream = ByteArrayInputStream(message) override fun readByte(): Int = delegatedStream.read() override fun resetStream() = delegatedStream.reset() companion object { fun decode( inputStream: InputStream, messageSize: Int ): Sequence<ByteArray> = sequence { while (true) { // read nonce val message = inputStream.readNBytes(messageSize) if (message.size != messageSize) break yield(message) } } } }这个类很简单：我们接受一个message数组作为被隐写的数据，然后我们把它包装成一个ByteArrayInputStream，并且将数组的大小作为payload的size，而读起来就直接调用输入流的read()，如果输入流结束了，返回-1，那么父类就会告诉子类从头开始（调用resetStream），而这个工作也直接委托给了输入流的reset()。这里还贴心地附上了解码函数。图片在解码之后将会作为一个输入流，而这里的decode则起到一个解密/逆变换的作用。为了兼容不同的LSB实现，这里也定义一个接口：interface LSB { fun encode( image: BufferedImage, messageStream: MessageStream, randomStream: RandomStream ): BufferedImage fun decode(image: BufferedImage, randomStream: RandomStream): InputStream }这里暂时先不用管RandomStream，它是给一些LSB提供随机性的，目前用不到。对于LSB，定义了两个方法：编码负责将消息隐写到提供的图片中，而解码则根据图片读取出其中的消息，将其表示为一个输入流。在此基础上还可以写一个抽象类来负责共同的逻辑：abstract class LinearLSB : LSB { protected abstract fun encodeRandom( x: Int, y: Int, originalARGB: Int, randomStream: RandomStream, messageStream: MessageStream ): Int protected abstract fun decodeRandom( x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream ): List<Boolean> final override fun encode( image: BufferedImage, messageStream: MessageStream, randomStream: RandomStream ): BufferedImage { val result = BufferedImage(image.width, image.height, image.type) for (y in 0 until result.height) { for (x in 0 until result.width) { val newColor = encodeRandom(x, y, image.getRGB(x, y), randomStream, messageStream) result.setRGB(x, y, newColor) } } return result } final override fun decode(image: BufferedImage, randomStream: RandomStream): InputStream = object : InputStream() { private val seq = sequence { var value = 0x00 var pointer = 0 for (y in 0 until image.height) { for (x in 0 until image.width) { decodeRandom(x, y, image.getRGB(x, y), randomStream).forEach { if (it) value = value or (0x01 shl pointer) pointer++ if (pointer >= Byte.SIZE_BITS) { yield(value.toByte()) pointer = 0 value = 0 } } } } } private var currentIter = seq.iterator() override fun read(): Int = if (currentIter.hasNext()) currentIter.next().toUByte().toInt() else -1 override fun available(): Int = if (currentIter.hasNext()) 1 else 0 override fun reset() { currentIter = seq.iterator() } } protected fun setBit(data: Int, mask: Int, messageBit: Boolean): Int = if (messageBit) { data or mask // set bit to 1 } else { data and (mask.inv()) // set bit to 0 } }由于之后实现的都是顺序遍历的LSB，所以这里搞了一个LinearLSB，它将顺序遍历每一个像素，然后让子类将信息编码到像素中。抽象的encodeRandom向子类提供当前像素的坐标和值，还有随机流以及信息流，子类返回经过编码后的像素值；解码时通过向子类提供类似的信息，子类返回一个比特集合。这是考虑到子类可以在一个像素中编码多个比特的信息，也可以不编码信息。三通道固定编码LSB接下来终于可以实现我们的第一个LSB隐写了。这个LSB隐写的实现比较简单，就是之前说的每个像素编码分别在RGB三个通道同时编码，即一个像素编码3个比特：class RGBFixedBit0LSB : LinearLSB() { override fun encodeRandom( x: Int, y: Int, originalARGB: Int, randomStream: RandomStream, messageStream: MessageStream ): Int { var data = originalARGB // R -> G -> B for (i in 2 downTo 0) { data = setBit(data, 0x01 shl i * 8, messageStream.nextBit()) } return data } override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> { val data = mutableListOf<Boolean>() // R -> G -> B for (i in 2 downTo 0) { data.add(pixelARGB and (0x01 shl i * 8) != 0) } return data } }在编码时，我们按照红、绿、蓝三个通道的顺序分别编码三个比特，解码时按照同样顺序读出这三个比特。编码效果如下：左边是原图，右边是编码后的图。这里编码的消息大小正好是768比特，而图片也正好是768比特，因此右图的右上角能隐约看到一些竖直方向的条带。关于生成图片的完整代码，以及后文提到的隐写分析的代码，可以参考GitHub Repo hurui200320/steganography-demo。隐写分析怎么知道隐写的效果好不好呢？或者说作为攻击者，我们如何分析一张图片有没有包含隐写内容呢？最简单的办法就是放到Photoshop里看直方图：左边是原图，右边是经过隐写的，你会发现隐写之后的直方图出现了一堆锯齿。为什么会这样呢？我们不妨来看看它的位平面。由于隐写只发生在每个通道的bit0上，所以我们只看bit plane 0：从上到下分别是RGB三个通道，左边是原图，右边是隐写后的图片。因为隐写过程完全覆盖了原有的值，所以覆写之后导致原本应该平滑的直方图，因为像素随着有规律的数据被迫变大或变小1位，因此变得具有明显的锯齿。原本的图片色彩越平滑，这个问题越严重。如果选用噪点多一些的图片，则能够在一定程度上缓解这个问题。最后我们来看看随机颜色映射（Random Color Map），这种分析方法将每一种颜色随机且唯一地映射到另一种颜色。这种变换能够将原本相邻的、肉眼无法察觉的细微变化，通过随即映射来放大：左边是原图，右边是隐写后的图。可以看到，虽然左侧的颜色已经变得乱七八糟，但是仍然能分辨出一些平滑的色块。而右边则很明显地能看出来我们隐写的数据。以上三种方法应该是目前我知道的最常见的方法了，除此之外还有基于统计学的方法，这些我没有找到太多资料，但整体思路大约是通过统计学特征（方差、熵等）来辨别隐写。况且基于统计学的方法大多是计算，没有什么可视化的过程，对于写文章来说有些无聊（但确实是最方便的，公式一贴，代码一贴，运行结果一贴就完事儿了），所以之后我也只使用前面这三种方法来分析隐写。请注意，隐写分析并不只是这几种方法，随着各学科不断发展，近年来也有使用人工智能和神经网络来分析隐写的方法。还是开头那句话：你需要自己评估并决定承担一种方法的风险。三通道随机编码LSB（Bit 0）如果说要改进前一种隐写方式，我首先能想到的就是引入随机性，并牺牲一些隐写容量。我们在一个像素中只编码一个比特，但是我们使用随机过程来决定在哪个通道中编码。这样一来也算是对被编码数据的一种保护。随机决定编码通道要求接收者使用同样的随机过程才能正确解读隐写内容，否则就会把图片的数据当成隐写内容读出。但这并不等同于加密，如果隐写的内容具备已知且明显的特征，比如说ASCII字符，那么通过剪枝就可以以8个像素位单位排除掉非法字符，然后以英文单词为基础排除掉无意义的组合，还是可以破解出隐写的内容。这里引入了RandomStream来提供随机过程：interface RandomStream { fun read(): Int }这里返回的数值范围在0到255之间。由于大部分随机源都是以字节数组的方式交互的，因此这里编写了一个抽象类来从字节数组中提供随机数：abstract class DelegatedRandomStream( protected val buffer: ByteArray ) : RandomStream { private var bufferPosition: Int = buffer.size protected abstract fun reloadBuffer() final override fun read(): Int { if (bufferPosition >= buffer.size) { reloadBuffer() bufferPosition = 0 } return buffer[bufferPosition++].toUByte().toInt() } }子类需要提供一个buffer，并根据父类的指示重载这个buffer。框架有了，问题是，我们要选择什么样的随机数算法呢？首先我们要求这个随机数必须是安全的。类似于生成随机密钥，你当然不想别人能够根据密钥的一部分而预测到后面的内容。针对这种需求，人们提出了CSPRNG这个概念，全称Cryptographically secure pseudorandom number generator，即密码学安全伪随机数生成器。其次我们还需要这个随机数生成器是可重复的。大多数情况下，密码学安全的伪随机数生成器只能保证它提供的随机性是不可预测的，最安全的随机数生成器包括基于大气噪声的random.org，还有基于硬件熵池的随机数生成器。但他们的问题在于不可重复，这样一来在解码时我们不知道随机序列，那就没办法解码了。我们当然可以把随机选择的通道记录下来，作为解码隐写信息的条件。但是这种方法产生的额外信息长度取决于图片大小，对于一张768*768的图片，这种信息很容易就能达到144KB，悬一悬都快赶上一张图片了。如果随机过程是可重复的，那我们只需要记录一个简短的seed就可以重复产生这个随机序列了。这里选择了两个随机数生成算法。一种是JVM自带的SHA1PRNG，这种算法通过SHA1散列函数来生成随机数。当本地随机数不可用时，JVM将退回到这种算法来为SecureRandom提供随机性。另一种是来自BouncyCastle的VMPC（Variably Modified Permutation Composition，我也不知道是什么意思，但是ChatGPT说这东西很安全），它的全称类名是org.bouncycastle.crypto.prng.VMPCRandomGenerator。关于这两个算法对应的RandomStream就不多赘述了，无非就是调用对应的生成器产生字节数据，然后交给DelegatedRandomStream处理。好奇的话这部分代码可以在info.skyblond.steganography.prng包下找到。接下来看看这种随机编码的LSB的实现：class RGBRandomBit0LSB : LinearLSB() { override fun encodeRandom( x: Int, y: Int, originalARGB: Int, randomStream: RandomStream, messageStream: MessageStream ): Int { // 0 -> Blue (7-0) // 1 -> Green (15-8) // 2 -> Red (23-16) val channel = randomStream.read() % 4 // skip if channel is 3 if (channel == 3) return originalARGB return setBit(originalARGB, 0x01 shl channel * 8, messageStream.nextBit()) } override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> { val channel = randomStream.read() % 4 // skip if channel is 3 if (channel == 3) return emptyList() return listOf(pixelARGB and (0x01 shl channel * 8) != 0) } }这里我们读了一个随机数，然后取4的余数，也就是低两位。0表示使用蓝色通道（LSB正好是bit 0）；1表示使用绿色通道（LSB是bit 8）；2表示使用红色通道（LSB是bit16）；如果遇到3，则这个像素不编码。解码时使用同样的seed初始化对应的RandomStream，可以保证在解码时将产生完全一样的随机序列。此时根据随机序列的结果读取对应的比特即可。我们来看看效果：左边是原图，右边是隐写后的图。虽然垂直方向的条带仍然隐约可见，但是不仔细看的话还是很难发现的。这是因为我们的数据恰巧是768比特，因此特征比较明显。接下来我们看看隐写分析的结果。首先是直方图。这次可以看到那些明显的锯齿消失了。我们看看bit plane 0：可以看到，这次隐写保留了大部分原有的信息，我们能够在位平面中辨识出原图的一些细节。但是由于隐写信息的存在，可以看到一些本该平滑的色块经过隐写后引入了噪点。这些噪点正是我们随机隐写的数据。最后我们看看随机颜色映射：这次没有那么明显的特征的，但是一张色彩平滑的图片包含那么多噪点，还是很可疑啊。三通道随机编码（Bit 0和1）以及数据加密虽然前面的方法隐蔽了特征，但还是留下了一些噪点。为了进一步稀释这些噪音，我们不妨把bit 1也加入到编码的行列中：class RGBRandomBit01LSB : LinearLSB() { override fun encodeRandom( x: Int, y: Int, originalARGB: Int, randomStream: RandomStream, messageStream: MessageStream ): Int { // 0 -> Blue (7-0) // 1 -> Green (15-8) // 2 -> Red (23-16) val channel = randomStream.read() % 4 // skip if channel is 3 if (channel == 3) return originalARGB val bit = randomStream.read() % 2 return setBit(originalARGB, 0x01 shl (channel * 8 + bit), messageStream.nextBit()) } override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> { val channel = randomStream.read() % 4 // skip if channel is 3 if (channel == 3) return emptyList() val bit = randomStream.read() % 2 return listOf(pixelARGB and (0x01 shl (channel * 8 + bit)) != 0) } }这样一来每个通道的值将在±2之间变化，这种变化对于肉眼来说一样是难以分辨的。这里我们不光随机挑选用于隐写的通道，我们还随机挑选用于隐写的位平面。这次除了直接写入数据，我们还可以对数据加密。一个恰当的加密算法应该使密文在统计学上看起来像白噪音：class EncryptedMessageStream( private val rawMessage: ByteArray, private val key: ByteArray, ) : DelegatedMessageStream() { // Each loop has different count, thus the result is different private var loopCount = 0 private var delegatedStream: InputStream override val extraSize: Int override val payloadSize: Int init { require(key.size == 32) val (nonce, payload) = encryptMessage(key, loopCount.toByteArray() + rawMessage) delegatedStream = ByteArrayInputStream(nonce + payload) extraSize = nonce.size payloadSize = payload.size } override fun readByte(): Int = delegatedStream.read() override fun resetStream() { loopCount++ val (nonce, payload) = encryptMessage(key, loopCount.toByteArray() + rawMessage) check(nonce.size == extraSize) check(payload.size == payloadSize) delegatedStream = ByteArrayInputStream(nonce + payload) } private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> { val cipher = GCMSIVBlockCipher(AESEngine()) val nonce = ByteArray(12) SecureRandom().nextBytes(nonce) cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(message.size)) val resultSize = cipher.processBytes(message, 0, message.size, result, 0) cipher.doFinal(result, resultSize) return nonce to result } companion object { fun decode( inputStream: InputStream, key: ByteArray, nonceSize: Int, payloadSize: Int ): Sequence<ByteArray> = sequence { while (true) { // read nonce val nonce = inputStream.readNBytes(nonceSize) if (nonce.size != nonceSize) break val payload = inputStream.readNBytes(payloadSize) if (payload.size != payloadSize) break try { // skip the 4 bytes loop counter yield(decryptMessage(key, payload, nonce).let { it.copyOfRange(4, it.size) }) } catch (t: Throwable) { // bad block, skip } } } private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray { val cipher = GCMSIVBlockCipher(AESEngine()) cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(encryptedMessage.size)) val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0) cipher.doFinal(result, resultSize) return result } } }这里选用了AES-256-GCM-SIV。AES-GCM算法能够均匀的加密数据，并且GCM自带验证，确保我们读到了正确的数据，同时SIV缓解了重用nonce的风险。同时为了避免同一段数据被不停的复读进而产生可辩别的模式，这里还引入了一个计数器。每次过完一次信息之后，会将计数器的值加一，并将计数器的值作为消息的一部分加密，这样每一次写入的数据都不一样。效果如下：这次看起来好像没什么破绽了，毕竟从原理上就是随机添加了一些噪点。接下来我们看看直方图：这次的直方图已经很接近原图了，但是由于隐写替换的缘故，我们不可能做到和原图一模一样。接下来看看位平面：因为这次隐写到了两个位平面，所以左侧是位平面0，右侧是位平面1，而每个位平面的左侧是原图，右侧是隐写之后的图，从上到下依次是RGB三个通道。可以看到每个位平面的噪音减少了很多。最后来看看随机颜色映射：嗯，看起来噪点还是很明显，无论怎么看还是很可疑。利用噪点虽然噪点在大多数时候很恼人，无论是摄影还是什么领域，我们都不希望出现噪点。但在隐写这个特殊领域中，噪点正是我们的好朋友。我前面的演示使用的是一张由AI生成的，整体色彩比较平滑的图片。所谓平滑，就是图片中存在大量颜色相同的色块，这种色块中只要出现一丁点噪声，就会显得不对劲。藏匿数据和撒谎其实没什么区别：如果一个谎言全是假的，那你编织得再完整，它依旧是假的；倘若你在其中掺杂一些真实的东西，那你的谎言就看起来非常真实了。对于藏匿数据来说，什么是真实的呢？答案是大自然。如果你对着细节丰富的东西拍一张照片，即便经过JPEG压缩，他还是细节丰富的：这张图即便经过JPG算法压缩，可是放大之后还是有很多噪点。如果没有原图的话，攻击者根本无法分辨这到底是照片本身如此，还是人为添加了噪点（隐写信息）。不过这里很重要的一点是没有原图。如果攻击者能够轻易获得原图，那么只要跟你的图片进行对比，立刻就能看出端倪。至于如何删除原图、藏匿隐写程序，这算是安全实践方面的内容，就不再本文的讨论之列了。如果你觉得自然风光的噪点不够，那么你有没有试过抬起头来仰望星空呢？结语本来我准备了8张图片来演示这个算法，但是除了AI生成的那个，剩下的图片基本上都以MB计算，并且大多数图片处理完之后都占用30MB上下，最大的甚至有50MB的。想来想去，为了我的服务器和读者的流量着想，我决定不把图放上来了。但是我有把完整的代码和用图放在GitHub仓库里，欢迎查阅。我想把这个系列写下去，并且下一篇文章打算写一写DFT（Discrete Fourier transform，离散傅里叶变换）隐写。实际上代码已经写了一部分了，但是测试了各种方法，我还没找到能够将数据稳定隐写在频域的办法。之后还想写写DCT（离散余弦变换，也就是JPG压缩算法使用的变换），最后ChatGPT还跟我说有一种DWT，离散小波变换，这说的我一脸茫然，虽然我是学计算机的不假，但是这听起来确实像是信号那一块的事情。我这大学四年就没学过信号相关的东西。看起来需要补充一下知识了。鉴于我后面想写的东西全都是我不熟悉的东西，所以如果这个系列变成坑了也请大家不要大惊小怪，可能是我真的没学会。-全文完-