无损旋转为什么会改变文件大小?

无损旋转为什么会改变文件大小?

Kevin Tsang Lv2

如果你有留意,你可能会发现这样的情况:对一张PNG图片用Windows自带的图片查看器/资源管理器进行旋转操作,却发现它的文件大小发生了改变。一个声称“无损”的操作,为什么会导致文件大小变化?这其中到底发生了什么?

为了弄清楚这个问题,我们可以进行一系列的分析和验证,最终用一个简单的Python脚本和哈希值,来揭示背后的技术原理。

无损旋转为何改变了文件大小?

当我们对一张图片进行90度、180度的旋转时,我们的直觉是这个操作不应该损失图片质量。但当操作对象是PNG这样的无损压缩格式时,文件大小的变化就显得很奇怪。

这个现象的核心在于,我们需要区分两个概念:

  1. 图像数据无损:指图片中每一个像素的颜色信息(RGBA值)在操作前后都保持绝对一致,画质没有任何损失。

  2. 文件二进制一致:指操作前后的两个文件,在二进制层面一个比特都不差。

Windows的旋转功能做到了前者,但没做到后者。

分析:解压、旋转、再压缩的工作流

当你点击“旋转”时,系统的后台操作实际上是这样一个三步走的过程:

  1. 解压:将PNG文件在内存中解压,还原成一个原始的、未压缩的像素位图。

  2. 旋转:在内存中对这个像素位图进行精确的数学旋转。这一步像素本身的颜色值不会有任何改变。

  3. 重新压缩:将旋转后的新像素位图,再次使用PNG的压缩算法(DEFLATE)进行压缩,并保存为一个新文件。

关键点在于第三步。PNG的压缩算法效率,高度依赖于像素数据的排列顺序。它通常是按“行”来寻找和压缩重复数据。

举个例子,一张包含水平渐变的图片,在原始状态下,每一行内部的像素颜色都很接近,压缩效率很高。但当它被旋转90度后,渐变方向变成了垂直。此时,如果压缩算法依然按行扫描,每一行都会包含剧烈的颜色变化,导致压缩效率降低,文件体积也就相应增大了。

反之,如果原始图片是垂直渐变,旋转后变为水平,文件体积反而可能会减小。

验证:用哈希值一探究竟

理论分析需要实验数据来支撑。我们可以设计了一个实验来验证这个过程。

实验思路
如果旋转操作在像素层面是真正无损的,那么将一张图片连续向同一方向旋转四次(360°),其核心的像素数据应该能完全恢复到原始状态。

验证方法
我们使用哈希算法(SHA256)来计算文件的“数字指纹”。但直接对文件计算哈希会受到元数据(Metadata) 的干扰。软件在每次保存文件时,都可能修改文件的元数据(如修改时间等),即使核心图像数据没变,这也会导致哈希值不同。

为了排除干扰,我们用Python的Pillow库编写了一个脚本。这个脚本会读取一张PNG图片,将其加载到内存中,然后再重新编码为PNG格式的字节流。这个过程能有效“清洗”掉大部分非必要的元数据,让我们能直接对图像的核心压缩数据进行比较。

实验结果与数据解读

我们准备了1.png(原始文件)及其连续旋转多次后生成的一系列文件,然后运行脚本计算它们“清理”后的哈希值。

结果如下:

文件名 状态 原始大小 清理后哈希 (SHA256)
1.png 原始 (0°) 657.40 KB 0d27…590c
2.png 旋转90° 743.15 KB d4ca…3619
3.png 旋转180° 652.90 KB ab04…ab0e
4.png 旋转270° 743.02 KB 602f…f5d4
5.png 旋转360° 658.08 KB 0d27…590c
6.png 旋转450° 743.15 KB d4ca…3619
7.png 旋转540° 652.90 KB ab04…ab0e

这组数据清晰地展示了几个事实:

  1. 旋转操作在图像层面是无损且可逆的:1.png(原始)和 5.png(旋转360°)在清理元数据后,哈希值完全一致。这证明了图像的核心像素数据完美地恢复到了初始状态。

  2. 文件大小的变化确实存在:1.png 和 5.png 的原始大小不同,证明了即使图像数据复原,文件本身也因为元数据等因素发生了改变。

  3. 旋转状态是周期性的:图像数据只在四种确定的状态(0°, 90°, 180°, 270°)之间循环,哈希值也相应地呈现周期性重复,不存在累积误差。

结论

通过这次简单的分析和验证,我们可以得出结论:

Windows资源管理器等工具对PNG图片的90度整数倍旋转,在图像质量上是无损的。你可以放心使用这个功能,不必担心它会像反复保存JPEG那样降低图片质量。

文件大小之所以会发生变化,是因为软件执行了“解压-旋转-重压缩”的流程。像素排列的改变影响了PNG压缩算法的效率,同时文件元数据也可能被修改,这两者共同导致了最终文件体积的变化。


附注:不同图像格式的旋转操作总结

既然我们搞清楚了PNG的原理,那么其他常见格式呢?它们在进行90度旋转时,行为是否一样?下面这个表格可以帮助你快速了解:

格式 主流软件的旋转方法 是否无损 (90/180/270度) 文件大小变化
BMP (无压缩) 解压 -> 旋转 -> 保存 是,绝对无损 不会变
PNG (无损压缩) 解压 -> 旋转 -> 重压缩 是,图像数据无损 可能会变
TIFF (无损压缩) 解压 -> 旋转 -> 重压缩 是,图像数据无损 可能会变
JPEG (有损压缩) 直接重排数据块,不解压 是,“真·无损” 基本不变
WebP (无损) 解压 -> 旋转 -> 重压缩 是,图像数据无损 可能会变
WebP (有损) 大多是解压 -> 旋转 -> 重压缩 通常有损! 可能会变
JPEG XL (JXL) 直接修改元数据 是,“真·无损” 基本不变

对JPEG和JXL行为的进一步说明

表格中JPEG和JPEG XL的行为比较特殊,值得单独解释一下:

  • JPEG的“真·无损旋转”
    JPEG是一种有损格式,如果采用和PNG一样的“解压-旋转-重压缩”流程,每次保存都会造成新的画质损失,这是不可接受的。因此,JPEG的无损旋转采用了一种巧妙的技术:它不解压图像。JPEG图像内部是由许多8x8像素的数据块(DCT块)组成的。90度的旋转可以通过直接对这些已压缩的数据块进行位置重排,并对块内部的DCT系数进行数学变换来实现。这个过程完全在压缩域内完成,因此不会引入新的压缩损失,速度极快,文件大小也基本不变。

  • JPEG XL (JXL)的先进设计
    JXL作为下一代图像格式,在设计之初就将无损变换作为核心功能。它的标准中包含了一个原生的“方向(Orientation)”标记。当需要旋转图像时,软件只需修改文件头部这个标记的值即可,巨大的图像数据主体完全不需要被触动。这可以说是最理想、最高效的旋转方式。此外,JXL还能将一个现有的JPEG文件无损地“包裹”起来,转换为JXL格式,之后便可以用JXL的这套高效机制来对它进行无损旋转,这也是其强大之处。

关于WebP格式的说明

同样需要注意的是,webp格式其“真·无损旋转”的支持不如JPEG普及和标准化。

  • 无损WebP:它的行为和PNG完全一样。同样采用“解压-旋转-重压缩”的流程,因为每一步都是无损的,所以最终结果也是图像数据无损,但文件大小可能会变。

  • 有损WebP:与JPEG不同的是,有损WebP并没有一个被广泛支持的“真·无损旋转”机制。因此,大多数软件只能采用“解压-旋转-重压缩”的通用方法。但关键在于,这最后一步的“重压缩”是有损的,会引入新一轮的画质损失,导致图像质量下降。

参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import hashlib
from PIL import Image
import io
import os

def get_clean_png_hash(filepath):
"""
读取PNG文件,去除元数据后重新编码,并计算其哈希值。

这个函数通过将图像加载到Pillow中,然后再将其保存到内存中的一个
字节流里,来达到去除大部分元数据的目的。Pillow在重新保存时,
不会写入原始文件中所有非必要的元数据块。

Args:
filepath (str): 原始图片文件的路径。

Returns:
str: 清理后图像数据的SHA256哈希值。
"""
try:
# 1. 打开图像文件
with Image.open(filepath) as img:
# 2. 创建一个内存中的字节缓冲区
# 这就像一个临时的、在内存中的虚拟文件
output_buffer = io.BytesIO()

# 3. 将图像以PNG格式保存到缓冲区
# 这是关键一步!Pillow会重新构建PNG,只保留必要的区块。
# 通过将 optimize=False 和 compress_level 设置为已知值,
# 我们可以尽量保证对于相同像素数据的压缩结果是一致的。
img.save(output_buffer, format='PNG')

# 4. 获取缓冲区中的全部字节数据
clean_image_data = output_buffer.getvalue()

# 5. 计算这段纯净数据的哈希值
sha256_hash = hashlib.sha256(clean_image_data).hexdigest()

return sha256_hash

except FileNotFoundError:
return "错误:文件未找到"
except Exception as e:
return f"发生错误: {e}"

# --- 主程序 ---
if __name__ == "__main__":
# 定义要处理的文件列表
image_files = [f"{i}.png" for i in range(1, 8)]

print("正在计算清理元数据后的PNG文件哈希值...\n")

for filename in image_files:
if os.path.exists(filename):
# 获取原始文件大小
original_size = os.path.getsize(filename)
# 计算清理后的哈希值
clean_hash = get_clean_png_hash(filename)
print(f"文件: {filename}")
print(f" - 原始大小: {original_size / 1024:.2f} KB")
print(f" - 清理后哈希 (SHA256): {clean_hash}\n")
else:
print(f"文件: {filename}")
print(" - 文件不存在\n")
  • Title: 无损旋转为什么会改变文件大小?
  • Author: Kevin Tsang
  • Created at : 2025-06-24 00:00:00
  • Updated at : 2025-06-24 18:08:49
  • Link: https://blog.infrost.site/2025/06/24/why_does_lossless_rotation_change_the_file_size/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments