# 前言

想做这个的原因有两点:

  1. Unity 的各项压缩格式,除 RGB 不压缩及 ASTC,基本都只支持 POT 分辨率的图,但是美术给的图相当不合规范
  2. 最近在给自己博客换了主题,因为 Shoka 随机壁纸采用的新浪图床也不再提供服务,想着直接把随机图片托管到 GitPages 算了,GitPages 的速度大家都知道,这就涉及到一个尽量压缩少大小,以期减少加载时间的问题

之前直接在 Unity 里边写过一个小脚本,可以批量一键将图片『截取』为指定分辨率。

由于利用的是 Unity Texture2D 类,这个类提供的处理方式很简单粗暴:直接按像素设置。所以只能做到 『截取』功能,没法做到『缩放』。

比如一个图片是 1920x1080 ,如果想将其分辨率改成 1280x720 ,得到的恐怕就只是这张图片其中的 一部分 了,例如:上半部分一小块、中间部分一小块,然后形成低分辨率。

如图所示:

也就是说 Texture2D 只能单纯『截取』,而且中间可能还会涉及到获取分辨率与设置分辨率差异量过大导致越界异常等问题。

昨天晚上回家后,查到 C# 其实就提供了对应的处理方法,可以利用 System.Drawing.ImageSystem.Drawing.Graphics 进行缩放操作,然后简单用 UnityEditor 写了个工具,可以根据图片宽度进行『截取缩放』,而非之前 Texture2D 那样单纯只能截取。

private static GUIStyle _style;
    [MenuItem("Textures/CutTexturesWindow")]
    public static void CutTesturesCenter()
    {
        _style = new GUIStyle();
        GetWindow<TexturesCutWindow>();
    }
    private string _path;
    // 截取中间部分
    private bool _isCutCenter;
    // 截取分辨率
    private Vector2Int _cutPixels = new Vector2Int(1280, 360);
    private void OnGUI()
    {
        EditorGUILayout.BeginHorizontal();
        if (GUILayout.Button("选择"))
            _path = EditorUtility.OpenFolderPanel("选择一个待处理文件夹", _path, "");
        EditorGUILayout.LabelField("目录:");
        if (string.IsNullOrEmpty(_path))
        {
            _style.normal.textColor = UnityEngine.Color.red;
            GUILayout.Label("请选择一个目录!", _style);
        }
        else GUILayout.Label(_path);
        EditorGUILayout.EndHorizontal();
        _isCutCenter = EditorGUILayout.Toggle("截取中间部分", _isCutCenter);
        _cutPixels = EditorGUILayout.Vector2IntField("分辨率", _cutPixels);
        if (GUILayout.Button("截取"))
        {
            if (string.IsNullOrEmpty(_path))
            {
                EditorUtility.DisplayDialog("提示", "请选择一个目录!", "OK");
                return;
            }
            CutTestures(_path, _isCutCenter, _cutPixels.x, _cutPixels.y);
        }
    }
    public static void CutTestures(string path, bool isCenter, int width, int height)
    {
        string[] textures = Directory.GetFiles(path, "*.jpg");
        string resultPath = path + "_CutResult_CenterPixel_" + isCenter;
        if (Directory.Exists(resultPath))
            Directory.Delete(resultPath, true);
        Directory.CreateDirectory(resultPath);
        string texPath;
        int texNum = textures.Length;
        for (int i = 0; i < texNum; i++)
        {
            texPath = textures[i];
            EditorUtility.DisplayProgressBar("提示", "处理中:" + texPath, (i + 1) / (float)texNum);
            Bitmap bitmap = ZoomImage(new Bitmap(texPath), width, height);
            bitmap.Save(Path.Combine(resultPath, Path.GetFileName(texPath)),);
            Debug.Log("处理完毕:" + texPath);
        }
        EditorUtility.ClearProgressBar();
    }
    // 等比例缩放图片
    private static Bitmap ZoomImage(Bitmap bitmap, int destWidth, int destHeight)
    {
        try
        {
            System.Drawing.Image sourImage = bitmap;
            Bitmap destBitmap = new Bitmap(destWidth, destHeight);
            System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(destBitmap);
            g.Clear(System.Drawing.Color.Transparent);
            g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
            g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
            // 尝试以宽度为基准,截取缩放
            g.DrawImage(sourImage, new Rectangle(0, 0, destWidth, destHeight), 0, 0, sourImage.Width, sourImage.Width / (destWidth / (float)destHeight), GraphicsUnit.Pixel);
            g.Dispose();
            sourImage.Dispose();
            return destBitmap;
        }
        catch
        {
            return bitmap;
        }
    }

这种方式实现的话,可以以图片宽度为基准,再确定是否截取高度,或者不足填充透明度 —— 也就是说支持基于宽度的缩放。

如图所示,是将 1920x1200 缩放至 700x300 分辨率:

后来一想:为啥不直接写成一个通用的工具,每次使用还得打开 Unity 么?
而且做成工具后,还可以根据需求,截取完毕后作一条龙处理:后续转其它例如 WebP 之类的格式。

# 需求

由于项目存在大量 NPOT 图片,导致压缩格式难以生效,大量图片实际处于 RGBA32 模式,且 Unity 自带的 POT 缩放会导致变形 (Sprite 也无法使用) 因此想要实现一个通用的 WPF 工具,可以大批量将图片正确重新缩放为 2N 次方或其它模式的工具,至少包含以下功能:

  1. 批量处理图片
  2. 直接缩放图片,完全将旧图缩放至新分辨率,不拉伸,不足处使用透明度填充
  3. 基于宽度或高度缩放图片,不足处裁剪
  4. 基于图片当前分辨率,缩放至最接近的 2N 次方分辨率,可选是否将宽高缩放至一致,不足以透明度填充
  5. 可以设定 X、Y 偏移量,丢弃偏移量之前的像素
  6. 可以直接选择最终存储格式,使其可以当做一个格式转换器使用
  7. 可以事先预览各个选项造成的结果

# 研究

于是在网上先是以 C# 图片处理库 这种关键字查了下

找到了一个 net-core-image-processing 的文章(对应翻译 【渣翻译】.NET Core 图片处理

最后选中了 ImageSharp 这个图像处理库,不为什么,就因为它是纯 C# 实现,感觉可能更方便。

Git 地址:ImageSharp

# 问题

在使用 WPF 过程中碰到了不少问题,毕竟以前虽然有用过,但都是简单用用,没有深入研究。

这次想实现一点 高级特性 功能就感觉不够用了,碰到了不少问题。

然后越记录越多,因此还是新开一个文档记录了,具体在:使用 WPF 问题记录

# 缩放方式

目前准备支持选择的缩放方式如下:

  • 不缩放:不会改变原本分辨率
  • 直接缩放:直接缩放为设置的分辨率,不足之处直接拉伸,会造成变形
  • 比例缩放:将原图缩放至指定分辨率时尽量保持比例不变,多余处填充透明度
  • 比例裁剪:以高度或宽度最大者为基准进行缩放,尽量保持比例不变,多余处直接裁剪
  • 基于宽度:以宽度为基准进行缩放,高度不足则以透明度填充,高度超过之处则裁剪
  • 基于高度:以高度为基准进行缩放,宽度不足则以透明度填充,宽度超过之处则裁剪
  • 填充缩放:如果图片小于设定分辨率,则不改变图片原有像素大小,不足之处以透明度填充;如果图片大于设定分辨率,则比例缩放
  • POT 缩放:高宽缩放至最接近 2N 次方的分辨率,尽量保持比例不变,不足处进行透明度填充

# 直接缩放

直接将原分辨率拉伸填满设定的新分辨率,会造成拉伸变形,实现要也是最简单的,直接调用接口默认就是这种模式。

# 比例缩放

自动基于高度或宽度,保持比例不变的情况下缩放至新图。

分析:

既然是保持比例不变,那么首先需要计算出原图的比例。

例如原图为 720x1280 需要缩放至 1280x720 :

  • 原始比例为:720/1280=0.5625
  • 保持不变的情况下,若基于宽度,则高度为:1280/0.5625=2275
  • 保持不变的情况下,若基于高度,则宽度为:720x0.5625=405
  • 由于此时基于宽度的情况下,高度将会越界,因此只能选择基于高度
  • 即新的分辨率为:405x720
  • 像素复制开始位置为:(1280-405)/2,(720-720)/2

基本代码如下:

int startX = 0, startY = 0;
// 先计算原图比例
float scaleRatio = image.Width / (float)image.Height;
// 再按照比例映射至新图时,什么分辨率合适
// 保持不变的情况下,若基于宽度
int newHeight = (int)(data.Width / scaleRatio);
// 保持不变的情况下,若基于高度
int newWidth = (int)(data.Height * scaleRatio);
// 新的正确比例情况下,哪个正确不越界
if (newHeight > data.Height)
{
    // 基于宽度,新的高度越界了那么,取基于高度
    newHeight = data.Height;
}
else
{
    // 基于高度,新的宽度越界了那么,取基于宽度
    newWidth = data.Width;
}
startX = (data.Width - newWidth) / 2;
startY = (data.Height - newHeight) / 2;
image.Mutate(x => x.Resize(data.Width, data.Height, data.ResamplerAlgorithm, new Rectangle(0, 0, image.Width, image.Height), new Rectangle(startX, startY, newWidth, newHeight), false));

效果如下:


当做完这个效果之后,点进去看了下接口,结果发现了如果采用 ResizeOptions 作为参数传递,有一个 ResizeMode 的枚举:

//
    // 摘要:
    //     Provides enumeration over how the image should be resized.
    public enum ResizeMode
    {
        //
        // 摘要:
        //     Crops the resized image to fit the bounds of its container.
        Crop,
        //
        // 摘要:
        //     Pads the resized image to fit the bounds of its container. If only one dimension
        //     is passed, will maintain the original aspect ratio.
        Pad,
        //
        // 摘要:
        //     Pads the image to fit the bound of the container without resizing the original
        //     source. When downscaling, performs the same functionality as SixLabors.ImageSharp.Processing.ResizeMode.Pad
        BoxPad,
        //
        // 摘要:
        //     Constrains the resized image to fit the bounds of its container maintaining the
        //     original aspect ratio.
        Max,
        //
        // 摘要:
        //     Resizes the image until the shortest side reaches the set given dimension. Upscaling
        //     is disabled in this mode and the original image will be returned if attempted.
        Min,
        //
        // 摘要:
        //     Stretches the resized image to fit the bounds of its container.
        Stretch,
        //
        // 摘要:
        //     The target location and size of the resized image has been manually set.
        Manual
    }

... 感觉看着有点不对劲,是不是又造轮子了?这个功能是 ImageSharp 就有的么...

然后稍微研究了下,好像真的是提供了基础的缩放设定的... 只是之前我一直使用普通传参方式调用,导致才发现这么个接口。

不过后续研究了下发现,自带的接口 Pad/BoxPad 确实类似的于上述我实现的缩放模式,不过仅限于缩小时,当指定分辨率比图更大时,自带的接口不会缩放图片大小,而是直接填充透明度 —— 这样功能更类似于 填充缩放 ,对比结果:

左边是我实现的方法,右边是自带的 Pad/BoxPad 模式。

于是比例缩放还是用自己方法,填充缩放则直接调用 ImageSharp 接口了。

# 比例裁剪

直接使用 Imagesharp 的 Crop 模式

# 基于宽度 / 高度

与上面自定义的算法其实是一样的,只是固定为宽度或高度为准,不再判断哪个边更『长』

基于宽度,就是把宽度当做不变的值,根据原图比例,重设新的宽度下的高度值:

case EScaleMode.WidthBase:
// 基于宽度,计算新的高度
int newHeight = (int)(data.Width / (image.Width / (float)image.Height));
image.Mutate(x => x.Resize(data.Width, data.Height, data.ResamplerAlgorithm, new Rectangle(0, 0, image.Width, image.Height), new Rectangle(0, (data.Height - newHeight) / 2, data.Width, newHeight), false));
break;

基于高度,把高度当做不变的值,根据原图比例,重设新的高度下的宽度值:

case EScaleMode.HeightBase:
// 基于高度,计算新的宽度
int newWidth = (int)(data.Height * (image.Width / (float)image.Height));
image.Mutate(x => x.Resize(data.Width, data.Height, data.ResamplerAlgorithm, new Rectangle(0, 0, image.Width, image.Height), new Rectangle((data.Width - newWidth) / 2, 0, newWidth, data.Height), false));

# POT 缩放

POT 指的 Power of two,即 2N 次方,即需求是计算 计算与指定数值最接近的 2N 次方

Unity 里边除了 ASTC 外,其它压缩格式均要求 POT 分辨率格式的图片

Unity 本身其实也自带了 POT 缩放的选项,不过它只是单纯拉伸图片,使其撑满缩放后的分辨率,会造成变形。

这里我则是想实现不拉伸的缩放:高宽缩放至最接近 2N 次方的分辨率,尽量保持比例不变,不足处进行透明度填充

# 缩放计算

这里计算最接近 2N 的方式大概有 3 种:

  • 除二计算 (自顶向下)
  • 乘二计算 (自底向上)
  • 二进制操作

其中二进制操作是在网上看到的方法

# 除二计算

指定数字 X 不断除二,直到 X 不再大于 0,期间计算了 N 次,那么 2N 次方就是最接近 X 的 POT 数。

代码如下:

int n = 1;
int x=1080;
do { x /= 2; n++; } while (x > 2);
Print(Math.Pow(2,n));

# 乘二计算

跟上一种方式类似

不过是从下往上判断,2 不断往上乘,直到获得的数值超出了 X

代码如下:

int n = 1;
int x = 1080;
while (n < x) { n *= 2; }
// 若取比 X 更小的 2N 数值
//n-=1
Print(n);

这种方式有个缺点,原因是由于采用比较方式,原数值并未参与 计算 ,测了一下大概是:只能取比 X 大的或者比 X 小的 POT 数值,并不能取到 最接近 的 2N 数值。

# 二进制操作

这是在网上看到的一种方法,通过位移计算:

int Calc(int x)
{
     x |= x >> 1;
     x |= x >> 2;
     x |= x >> 4;
     x |= x >> 8;
	 x |= x >> 16;
     x += 1;
	 // 比 X 小的 2N 数值
     return x >> 1;
	 // 比 X 大的 2N 数值
	 return x;
 }

缺点与 乘二计算 一致,只能取比指定数字 X 更大,或者更小的 2N 数值(除非再增加额外判断或者改造)

实现方式感觉很巧妙,于是简单分析了下,以数字 100 为例:

int x=100;
Convert.ToString(x, 2)//		1100100
Convert.ToString(x >> 1, 2)//	 110010
Convert.ToString(x >> 2, 2)//	  11001
Convert.ToString(x >> 4, 2)//	    110
Convert.ToString(x >> 8, 2)//	      0
Convert.ToString(x >> 1|x >> 2, 2)//					111011
Convert.ToString(x >> 1|x >> 2 | x >> 4, 2)//			111111
Convert.ToString(x >> 1 | x >> 2 | x >> 4|x >> 8, 2)//	111111

可以发现,100 直接转二进制是有 7 位的,

  • 当右移一位后,变成了 6 位
  • 右移二位后,变成了 5 位
  • 右移四位后,变成 3 位
  • 右移八位后,变成 1 位 0 (这是因为 100 太小了)

每次或运算,都会使得对应位数变成 1,最后会使得整个二进制全部变成 1,即 『 1111111 』(长度以右移一位为准),同样的位数全部为 1 肯定会比原值更大,不过这种格式转为十进制后,是 2N-1 ,因此 + 1 之后就变成『 10000000 』这种格式转为十进制即为 2N


上面的分析是否准确呢?

其实,恰好因为我选的数字是 100,才造成了这种 看似正确 的结果,实际上是 有误 的。

仔细看原算法,每次 或运算之前 ,x 的值都是之前的修改过的值基础上进行,而在上述使用 100 为例的分析中,打印的都只是 100 直接位移之后的二进制值。

如果换一个数字,例如 128 :

int x=128;
Convert.ToString(x >> 1 | x >> 2 | x >> 4|x >> 8, 2)
"1101000"

是吧,完全乱了。

如果想依照这种算法计算,那么每次位移递增都只能是 1 位,而非 2N 递增。

之所以能够 2N 递增的原因是,与前值进行位移后,就包含了之前的

例如,还是以 128 为例:

128=      "10000000"
"10000000"|"1000000"(128>>1)="11000000"
"11000000"|" 110000"(11000000>>2)="11110000"
"11110000"|"  1111"(11110000>>4)="11111111"
"11111111"|"     0"(11111111>>8)="11111111"

因为每次或运算都是在前者基础上进行,因此可移动的位数是倍增的,在二进制位移层面上,倍增的表现就是以 2N 递增。

  • 第一次移动 1 位 (变成 2 个单位)
  • 第二次移动 2 位 (变成 4 个单位)
  • 第三次移动 4 位 (变成 8 个单位)
  • 第四次移动 8 位 (变成 16 个单位)

当然,由于这里 128 本身只有 8 位,右移 8 位 后结果直接为 0,因此实际到第三次移动时就已经确定了结果,第四次属于无效操作。

所以在上面的算法中,最高右移 8 位,即代表最高支持 2^16 次方数值:65535 及以下

测试结果很简单,加上打印,并传入 65536:

> int Calc(int x)
. {
.     Print(Convert.ToString(x, 2));
.     x |= x >> 1;
.     Print(Convert.ToString(x, 2));
.     x |= x >> 2;
.     Print(Convert.ToString(x, 2));
.     x |= x >> 4;
.     Print(Convert.ToString(x, 2));
.     x |= x >> 8;
.     Print(Convert.ToString(x, 2));
.     x += 1;
.     Print(Convert.ToString(x, 2));
.     // 比 X 大的 2N 数值
.     return x;
. }

输出:

> Calc(65536)
"10000000000000000"
"11000000000000000"
"11110000000000000"
"11111111000000000"
"11111111111111110"
"11111111111111111"
131071

65536 的下一个 2N 应该是 131072 ,这里计算出来直接少了一位了。

同理可得加上第四次移动 x >> 16 ,理论上最大可以支持 2^32 次方。

# 采用算法

考虑了一下,决定使用位运算的方式,毕竟这看着就比前两种暴力算法厉害。

另外,由于我这里是对分辨率的修改,没有那么高的数值需求 —— 至少感觉不可能有那么大的分辨率,因此最多移动四次即可。

最后再加上接近判断,就可以弄出三个函数分别代表 最接近更小更大 ,与 Unity 提供的 POT 缩放模式一致:

/// <summary>
/// 计算给定数值的 POT(根据缩放模式返回给定数值接近的 2N 数值)
/// 最大支持 65535
/// </summary>
/// <returns></returns>
private static int CalcPot(int x, EPotMode mode)
{
    int oldX = x;
    x |= x >> 1;
    x |= x >> 2;
    x |= x >> 4;
    x |= x >> 8;
    x += 1;
    int minPot = x >> 1;
    switch (mode)
    {
        case EPotMode.ToNearest:
            // 判断哪个更接近 X
            return x - oldX > oldX - minPot ? minPot : x;
        case EPotMode.ToLarger:
            // 比 X 大的 2N 数值
            return x;
        case EPotMode.ToSmaller:
            // 比 X 小的 2N 数值
            return minPot;
    }
    // 出问题了?
    return oldX;
}

# Bool 字节对齐

在重构代码,以使枚举可以更方便支持 WPF 数据绑定时,我采用了扩展函数的形式,并对数据进行了缓存。

由于定义的结构体,特意注意了一下字节对齐,发现 1 个 bool 变量 + 2 个指针变量是 24 个字节

再增加一个 bool 变量也是 24 个字节,但加到 3 个就不对了,变成了 32 个字节。

然后就单独分析了下,直接调用 sizeof 确实是 1 个字节,但放结构体使用 Marshal.SizeOf 就变成 4 个字节了:

> sizeof(bool)
1
> private struct CacheData
. {
.     public bool IsAddFirstName;
. }
> Marshal.SizeOf(typeof(CacheData))
4

其实,不只是放结构体, Marshal.SizeOf 直接获取到的大小就是 4 个字节:

> System.Runtime.InteropServices.Marshal.SizeOf(typeof(bool))
4

其它比如 Char 类型,sizeof=2,Marshal.SizeOf=1

以前还没注意到,这里需要注意下。

# 可空类型泛型模板需要返回 Null 值问题

在扩展枚举之后,又有一个新的需求:因为 ImageSharp 中的自动检测设置采用的可空类型,若类型为空则自动检测。

为了顺应同样的规则,Wrap 设置我也一样定义为了可空类型

然后我有一个函数,可以将下标转枚举,第一位是后期加上的一个字符串名字『自动』,如图所示:

这个『自动』并非枚举中的值,当使用者选择『自动』时,我会将对应枚举变量设置为空 —— 因此就有了一个问题:可空类型的模板方法,如何返回空值?

直接 return null 会报错并提示:

解决方式很简单 (虽然花了点时间)

顺应可空了类型本身的实现原理去分析了下:我们都知道可空类型本身是 Nullable 的二次封装,那么是否可以返回 Nullable 类型以解决这个问题?

如果想要 return new Nullable<T>() ,泛型约束必须加上 struct ,如下所示:

public static T? IndexToEnumNullable<T>(this int index, bool isAddFirstName = false, string? addFirstName = null) where T : struct, Enum
{
    Type type = typeof(T);
    CacheData data = GetCacheNames(type, isAddFirstName, addFirstName);
    object value;
    if (Enum.TryParse(type, data.Names[index], out value))
        return (T)value;
    return new Nullable<T>();
}

测试了下,外部接收者可空类型枚举确实变成 null

然后我又试了下,其实加上 struct 的泛型约束后,直接返回 null 也是可以的。

# 最后

现在是 2023 年 1 月 21 日,春节的前一天,除夕。这两天断断续续把最后的多线程批量处理功能加上去了,第一版暂且就这样了:

对比了一下起初自己提到的各个需求,除了偏移功能,应该也都完成了:

另外批量处理图片采用多线程处理 —— 同时开启处理量取决于 CPU 核心数目,以后也会会加个自定义数量?


在开始弄之前,可没想到会花这么长时间 —— 其实大多数时间都花在搞 UI 上去了... 做实际处理逻辑的功能虽然也花时间,但相比下来就不算什么了。

今年也没回家,今天已经是除夕了,先把功能收尾了。暂且这样吧,后边自己试用过后再看有没有想完善的再完善下。

最后,项目地址:GitHub