[PPTX解析] 图片效果算法篇:柔化边缘

内容纲要

PPTX解析:柔化边缘

PPT中可以对形状和图片进行柔化边缘操作(如图所示),其本质上可以看为对一个可视化对象的呈现进行视觉处理操作。通过本篇内容,我们将介绍柔化边缘的存储相关,并将说明我们如何实现近似相同的效果(因为使用的算法会导致最终生成的效果会有细微的差别)。在本案例中,将通过对一张图片进行处理,来理解柔化边缘的实现。

image

存储解析

PPT对图片进行柔化边缘这个行为,并不会对原图进行修改,而是通过将修改信息直接存入xml中,并在加载图片时通过计算将效果渲染出来。由于PPT不会存储一张经过该效果处理后的图片,所以第三方应用需要主动获取相关的存储信息,解析后将原图进行修改或通过着色器处理渲染效果。

首先,让我们通过存储节点来看一下PPTX中柔化边缘效果存放在哪一个节点中:

<p:pic>
    ......
    <p:spPr>
        ......
        <a:effectLst>
            <a:softEdge rad="635000" />
        </a:effectLst>
    </p:spPr>
    ......
</p:pic>
节点名称 含义 值含义
p:pic 图片 此元素指定文档中的图片对象的存在
p:spPr 形状属性 此元素指定图片对象具有形状属性(没错,PPT中图片和形状共用一部分属性)
a:effectLst 效果列表 存放特殊效果(阴影、发光、柔化边缘等)的列表
a:softEdge 柔化边缘效果 柔化边缘效果,具有属性<a:softEdge />

该效果节点<a:softEdge />的属性如下:

属性名称 属性含义 值含义 补充说明
rad 效果半径 柔化边缘的效果半径 英制公制单位(English Metric Unit)。用于对接厘米("Cm")和英寸("Inch")的虚拟单位。其特殊的数值设计,便于让你在转换百位以内的英寸和毫米、像素长度时,不会产生小数。

注:我们可以根据需要参考下面的代码将英制公制单位的值转为像素值。

/// <summary>
/// 将英制公制单位 <paramref name="emu"/> 转换为像素值
/// <param name="emu">英制公制单位的值</param>
/// <param name="dpi">设备DPI</param>
/// </summary>
public static double ToPixel(double emu, double dpi)
{
    return emu / 914400 * dpi;
}

效果实现

下面我们通过两种方式实现柔化边缘的效果。

效果实现(OpenCv)

本方案仅提供OpenCv实现的思想,而不提供代码实现。因为在C#的项目中使用OpenCv的库代价太大了(体积50M+),让人直呼受不了!所以我们将在下面使用C#编写简单的算法代替将要使用到的OpenCv算法。

首先,柔化边缘所要用到的算法基于以下两个点:

  • 图像腐蚀
  • 图像模糊

图像的柔化边缘的本质是将图片的Alpha通道进行腐蚀,然后将Alpha通道进行模糊处理。需要注意的一点是需要将超出图像的位置Alpha通道视为透明,并且参与腐蚀运算(可以视为图像的最外圈像素的Alpha值为0)。

因此,我们只需要基于OpenCv的erode()函数将图片的Alpha通道进行3次腐蚀操作,再通过blur()函数将图片的Alpha通道进行3次模糊操作,就能获得和PPT柔化边缘的近似效果。但根据选择的模糊算法,生成的最终效果也会有区别。下面的实现中,我们将通过C#来实现erode()和blur()的效果。

效果实现(C#)

下面我们将通过自己的算法实现腐蚀模糊操作,进而实现柔化边缘的效果。

需要注意的内容:

  • 如果希望将PPT的效果和以下代码实现的近乎一致,记得将柔化半径进行转换(PPT中使用的是英制公制单位,而下面的案例使用的是像素单位)。
  • 在进行效果处理时,建议将图片缩放至真实显示的尺寸再进行处理(例如原图是4K大小,实际显示的是720P的大小,那么我们应该对720P的尺寸进行计算提高运算效率)。
/// <summary>
///     PPTX柔化边缘效果
/// </summary>
public class ImageEffect
{
    /// <summary>
    ///      根据原始图片<paramref name="source"/>创建带有柔化边缘的图片
    /// </summary>
    /// <param name="bitmap">源图片</param>
    /// <param name="radius">柔化半径(单位:像素)</param>
    /// <returns></returns>
    public Bitmap CreateSoftEdgeBitmap(Bitmap bitmap, float radius)
    {
        var cols = bitmap.Width;
        var rows = bitmap.Height;

        //克隆一个32位ARgb图片,用于读取Alpha通道
        var image = bitmap.Clone(new Rectangle(0, 0, cols, rows), PixelFormat.Format32bppArgb);
        SetSoftEdgeEffect(image, radius);
        return image;
    }

    /// <summary>
    ///     为原始图片<paramref name="source"/>设置柔化边缘的效果
    /// </summary>
    /// <param name="source">源图片(必须是32位带Alpha通道的图片)</param>
    /// <param name="radius">柔化半径(单位:像素)</param>
    /// <returns></returns>
    public void SetSoftEdgeEffect(Bitmap source, float radius)
    {
        var pixelFormat = source.PixelFormat;

        if (pixelFormat != PixelFormat.Format32bppArgb)
        {
            throw new NotSupportedException($"Unsupported image pixel format {nameof(pixelFormat)} is used.");
        }

        //锁定图片并拷贝图片像素
        var cols = source.Width;
        var rows = source.Height;
        var rect = new Rectangle(0, 0, cols, rows);
        var channels = System.Drawing.Image.GetPixelFormatSize(PixelFormat.Format32bppArgb) / 8;
        var total = cols * rows * channels;
        var data = new byte[total];
        var bitmapData = source.LockBits(rect, ImageLockMode.ReadWrite, source.PixelFormat);
        var iPtr = bitmapData.Scan0;
        Marshal.Copy(iPtr, data, 0, total);

        //通过算法设置柔化边缘效果
        SetSoftEdgeEffect(data, cols, rows, channels, radius);

        Marshal.Copy(data, 0, iPtr, total);
        source.UnlockBits(bitmapData);
    }

    /// <summary>
    ///     设置柔化边缘效果
    /// </summary>
    /// <param name="data"></param>
    /// <param name="cols"></param>
    /// <param name="rows"></param>
    /// <param name="channels"></param>
    /// <param name="radius"></param>
    private void SetSoftEdgeEffect(byte[] data, int cols, int rows, int channels, float radius)
    {
        //创建并提供Alpha蒙层来进行腐蚀和模糊
        var mask = CreateSoftEdgeAlphaMask(data, cols, rows, channels);

        var offsetX = (int)Math.Round(radius / 4.0);
        var offsetY = (int)Math.Round(radius / 4.0);
        var size = new Size(offsetX, offsetY);

        //腐蚀操作
        mask = AlphaErode(mask, size, 3);
        //模糊操作
        mask = AlphaBlur(mask, size, 3);

        //应用Alpha蒙层数据
        ApplySoftEdgeAlphaMask(data, mask, cols, rows, channels);
    }

    /// <summary>
    ///     创建Alpha通道蒙层数据
    /// </summary>
    /// <param name="data">图像原始数据</param>
    /// <param name="cols">图像宽度</param>
    /// <param name="rows">图像高度</param>
    /// <param name="channels">图像通道数</param>
    /// <returns></returns>
    private byte[,] CreateSoftEdgeAlphaMask(byte[] data, int cols, int rows, int channels)
    {
        //根据宽高设置一个蒙层数组
        var masks = new byte[cols, rows];

        //需要考虑大小端
        var isLittleEndian = BitConverter.IsLittleEndian;
        for (var row = 0; row < rows; row++)
        {
            for (var col = 0; col < cols; col++)
            {
                var indexOffset = (row * cols + col) * channels;
                var alpha = isLittleEndian ? data[indexOffset + 3] : data[indexOffset + 0];
                masks[col, row] = alpha == 0 ? (byte)0 : byte.MaxValue;
            }
        }

        return masks;
    }

    /// <summary>
    ///     应用Alpha通道蒙层数据
    /// </summary>
    /// <param name="data">图像原始数据</param>
    /// <param name="mask">图像Alpha蒙层</param>
    /// <param name="cols">图像宽度</param>
    /// <param name="rows">图像高度</param>
    /// <param name="channels">图像通道数</param>
    /// <returns></returns>
    private void ApplySoftEdgeAlphaMask(byte[] data, byte[,] mask, int cols, int rows, int channels)
    {
        //需要考虑大小端
        var isLittleEndian = BitConverter.IsLittleEndian;
        for (var row = 0; row < rows; row++)
        {
            for (var col = 0; col < cols; col++)
            {
                var indexOffset = (row * cols + col) * channels;
                var index = isLittleEndian ? indexOffset + 3 : indexOffset;

                //根据蒙层设置Alpha
                var alpha = (byte)(mask[col, row] / 255.0d * data[index]);
                data[index] = alpha;
            }
        }
    }

    /// <summary>
    ///     对Alpha蒙层进行腐蚀操作
    /// </summary>
    /// <param name="sourceMask">输入蒙层数据</param>
    /// <param name="size">腐蚀操作卷积核大小</param>
    /// <param name="iteration">连续腐蚀次数</param>
    /// <returns>输出蒙层数据</returns>
    private byte[,] AlphaErode(byte[,] sourceMask, Size size, uint iteration)
    {
        var offsetX = size.Width;
        var offsetY = size.Height;

        var cols = sourceMask.GetLength(0);
        var rows = sourceMask.GetLength(1);
        var erodeMask = new byte[cols, rows];

        for (var i = 0; i < iteration; i++)
        {
            var target = new byte[cols, rows];
            var mask = sourceMask;

            //下面的卷积操作会尽可能减少不必要的运算过程
            Parallel.For(offsetY, rows - offsetY, row =>
            {
                var isNeedInitialize = true;
                var blackPointCols = new List<int>();

                for (var col = offsetX; col < cols - offsetX; col++)
                {
                    var minCol = col - offsetX;
                    var maxCol = col + offsetX;
                    var minRow = row - offsetY;
                    var maxRow = row + offsetY;

                    if (isNeedInitialize)
                    {
                        for (var x = minCol; x <= maxCol; x++)
                        {
                            for (var y = minRow; y < maxRow; y++)
                            {
                                if (mask[x, y] == 0)
                                {
                                    blackPointCols.Add(x);
                                    break;
                                }
                            }
                        }

                        isNeedInitialize = false;
                    }
                    else
                    {
                        blackPointCols.Remove(minCol - 1);
                        for (var y = minRow; y < maxRow; y++)
                        {
                            if (mask[maxCol, y] == 0)
                            {
                                blackPointCols.Add(maxCol);
                                break;
                            }
                        }
                    }

                    if (blackPointCols.Count == 0)
                    {
                        target[col, row] = byte.MaxValue;
                    }
                }
            });

            sourceMask = target;
            erodeMask = target;
        }

        return erodeMask;
    }

    /// <summary>
    ///     对Alpha蒙层进行模糊操作(使用归一化框过滤器模糊图像,是一种简单的模糊函数,是计算每个像素中对应核的平均值)
    /// </summary>
    /// <param name="sourceMask">输入蒙层数据</param>
    /// <param name="size">模糊操作卷积核大小</param>
    /// <param name="iteration">连续腐蚀次数</param>
    /// <returns>输出蒙层数据</returns>
    private byte[,] AlphaBlur(byte[,] sourceMask, Size size, uint iteration)
    {
        var offsetX = size.Width;
        var offsetY = size.Height;

        var cols = sourceMask.GetLength(0);
        var rows = sourceMask.GetLength(1);
        var blurMask = new byte[cols, rows];

        for (var i = 0; i < iteration; i++)
        {
            var target = new byte[cols, rows];
            var mask = sourceMask;

            //下面的卷积操作会尽可能减少不必要的运算过程
            Parallel.For(0, rows, row =>
            {
                var isNeedInitialize = true;
                var valueCache = new Dictionary<int, int>();

                for (var col = 0; col < cols; col++)
                {
                    var minCol = col - offsetX;
                    var maxCol = col + offsetX;
                    var minRow = row - offsetY;
                    var maxRow = row + offsetY;

                    var count = (offsetX * 2 + 1) * (offsetY * 2 + 1);
                    if (count == 0) count = 1;

                    if (isNeedInitialize)
                    {
                        for (var x = minCol; x <= maxCol; x++)
                        {
                            var value = 0;
                            if (x > 0 && x < cols)
                            {
                                for (var y = minRow; y < maxRow; y++)
                                {
                                    if (y > 0 && y < rows)
                                    {
                                        value += mask[x, y];
                                    }
                                }
                            }

                            valueCache.Add(x, value);
                        }

                        isNeedInitialize = false;
                    }
                    else
                    {
                        var value = 0;
                        valueCache.Remove(minCol - 1);
                        if (maxCol > 0 && maxCol < cols)
                        {
                            for (var y = minRow; y < maxRow; y++)
                            {
                                if (y > 0 && y < rows)
                                {
                                    value += mask[maxCol, y];
                                }
                            }
                        }

                        valueCache.Add(maxCol, value);
                    }

                    var targetValue = valueCache.Values.Sum() / (double)count;
                    target[col, row] = (byte)Math.Round(targetValue);
                }
            });

            sourceMask = target;
            blurMask = target;
        }

        return blurMask;
    }
}

实现的效果

file

GitHub项目仓库:

如果希望参考完整案例,请参考下面的项目:
柔化边缘案例

本文会经常更新,请阅读原文:https://imxcg.com/technology/dot-net/pptx-analysis/softedge,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 仙尘阁 (包含链接: https://imxcg.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 (imxcg@foxmail.com)