65.9K
CodeProject 正在变化。 阅读更多。
Home

在 VB.NET 中为图像创建半透明水印

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.71/5 (29投票s)

2005 年 1 月 18 日

14分钟阅读

viewsIcon

218272

使用 VB.NET 将半透明水印应用于 JPEG 图像。

引言

在图形处理中,经常需要为图像添加某种“水印”。我说的不是数字签名或机器可读的隐藏文本,尽管一些图像处理应用程序使用“水印”一词来指代这些。这里,我只是指某种可见的版权声明、徽标、图章或文本——基本上,任何形式的视觉元素,都表示“嘿,这张图片属于某某人”,作为对图像本身的一种小的版权保护措施。

在 VB 4、5 和 6 的时代,要做到这一点可能相当复杂。然而,如今 .NET 框架中的 GDI+ 组件消除了这类任务中的大部分困难。事实上,我自己的水印辅助类的代码模块包含惊人的 122 行代码,包括空格、未使用函数和一些遗留的测试代码。这应该不会太令人生畏,是吧?网上已有许多关于如何为图像添加水印的文章,所以与其重复别人已经写过的,我更想描述我需要处理的一个具体场景,处理我在其他方法中遇到的需要解决的一些问题,然后描述最适合我这个场景的解决方案。

背景与历史

以下是可能需要为图像添加水印的两个场景。假设有人想将他们的数码照片发布到照片托管网站,但他们不希望自己的照片被他人随意窃取而未经许可使用,而且他们也不想一次又一次地提醒艾德娜阿姨在互联网上可以在哪里找到他们的家庭相册。或者,假设您的客户是一位专业摄影师,他想提供给客户一些作品,展示他超高分辨率设备的全部辉煌,但他又不希望冒着客户在不付费的情况下使用这些作品的风险。在这两种场景下,水印都能很好地解决问题。我们需要在图像上添加一些简单的识别信息——可能是一个公司名称、一个徽标或相册的 URL——这会很难被删除或隐藏。大多数人不会经常使用 PhotoShop、Paint Shop Pro 或类似软件,同样,大多数人也不想花时间或精力去学习和掌握这些应用程序。在这种情况下,我认为这个场景 justifies 创建一个简单的应用程序,该应用程序可以简化添加图像水印的过程。让我们看看需求。

  1. 图像水印必须醒目且难以删除,例如简单地裁剪掉照片。将水印半透明地覆盖在图像上(就像电视屏幕右下角的网络图章)似乎是实现这一目标的一个相当直接的方法。
  2. 水印需要由用户输入的文本生成。
  3. 由于大多数普通用户不知道如何使用字符映射表或 [Alt+0169] 来生成正确的 © 版权符号,我们可以为用户自动附加它。
  4. 这是一个快速项目,所以用户界面应该非常简单,大多数功能对用户来说都是“自动的”,例如:
    • 图像输入
    • 水印放置
    • 水印透明度
    • 图像输出
  5. 在不控制原始/输入图像的尺寸、纵横比、亮度或对比度的情况下,水印需要在各种图像类型上看起来都不错,而无需用户进行设置。

入门

鉴于上述要求,此应用程序的基本设计需要解决三个主要功能:

  1. 提供一个用户界面,让用户指定输入目录、输出目录和水印文本。为了简单起见,我们将使用 `Directory` 对象返回的文件名数组进行循环,只获取输入目录中的所有 JPEG 图像。
  2. 给定一个 JPEG 文件的路径和一个代表我们想要添加在图像上的水印的字符串,我们需要:
    1. 通过路径和文件名获取一个 `Bitmap` 对象;
    2. 创建一个满足上述要求的、水印字符串的图形表示……
      • 阴影可以帮助在各种背景下提供更好的对比度。
      • 半透明水印允许放置在图像区域内。
      • 水印的大小应该适合正在应用它的图像。
    3. 通过将水印应用于源位图来创建一个输出 `Bitmap`。
  3. 将输出 `Bitmap` 保存到输出目录中的文件。

第一步:创建基本的用户界面

在我们可以处理任何图像之前,我们需要一些要处理的图像(是的,需要)。由于本文不是关于文件处理或 Windows Forms 的,我将推荐使用 CodeProject 另一篇文章——Alberto Venditti 的图像批量调整器——的代码来完成这部分。该项目提供了一个外壳,可以以我们所需的方式处理文件——获取输入目录、输出目录、过滤输入文件为 JPEG,然后循环遍历这些输入文件以对其进行位图处理。为了快速高效地推进,只需在 `frmMain` 中添加一个文本框,用于从用户那里获取所需的水印文本。至于图像处理本身,我们需要对 `frmMain` 中的 `btnGo_Click` 过程进行一些更改,特别是通过调用我们将在下一节中创建的函数来替换对 Alberto 代码中“`Reduce`”子例程的调用。[注意——在里面的时候也看一下那段代码,因为它演示了其他非常实用的技术,例如使用内存流处理图像、用最少的代码缩放位图,以及将位图和/或流保存到文件。]

第二步:对图像文件进行基本的水印处理

由于这是我第一次使用 .NET 的图像功能,我做了任何一个好的开发人员都会做的事情——搜索 [VB.NET 图像水印]。Jaison John 的 CodeProject 文章运行时水印网站图像是我最早找到的好资料之一。除了提供一些漂亮的 ASP.NET 集成技巧外,Jaison 还演示了如何完成我们项目中的一些基本工作。我从 Jaison John 的主要水印例程中提炼出的核心思想是:

  • 使用 .NET 框架 `Bitmap` 对象的一个重载构造函数从磁盘上的文件获取 `Bitmap` 对象。`Bitmap` 是 .NET 中表示图像的主要对象,我们将用来构造对象的重载接受一个表示图像文件路径的 `String` 参数。
    Dim bmp as Bitmap = New Bitmap(strInputFilePath)
  • 使用 `Graphics.FromImage(Bitmap)` 从 `Bitmap` 获取 `Graphics` 对象。`Graphics` 类是 .NET 提供的一个实用类,用于操作和修改它创建的 `Bitmap`。
  • 使用 `Graphics` 类的 `.DrawString` 方法将文本绘制到 `Bitmap` 上。

这是一个很好的开始。Jaison 的技术用米色在给定的图像上绘制 14 点的 Verdana 文本,从像素 (0,0) 开始,如下所示(代码是 Jaison 代码的一个稍作修改的版本,注释是我的):

    'Construct a Bitmap object from a jpg's filename:
    dim bmp as Bitmap = New Bitmap(strInputFilePath)
    'Obtain a Graphics object from & for that Bitmap:
    dim canvas as Graphics = Graphics.FromImage(bmp)
    'Draw the watermark string onto the Bitmap:
    canvas.DrawString(strWatermark, _ 
        New Font("Verdana", 14, FontStyle.Bold), _
        New SolidBrush(Color.Beige), 0, 0)
    'Save the watermarked bitmap to a new file:
    bmp.Save(strOutputFilePath)

这么短的代码块,还不赖吧?不过,根据我们的要求,我们还有一些工作要做,才能让我们的水印成型。[注意:本文使用的类位于 `System.Drawing` 命名空间中,因此如果您是从头开始编写代码,请将该命名空间的 `Imports` 语句添加到您的代码模块中,以节省时间。] 这就引出了……

第三步:创建阴影

经过一番试错,我发现对 `.DrawString` 的连续调用默认会创建堆叠在一起的文本实例——也就是说,每个渲染文本的 Z 顺序会随着每次调用 `.DrawString` 而增加。回到上一节的代码块,我们可以看到 `Graphics` 对象的 `.DrawString` 方法接受五个参数:

  • 要绘制的 `String` (strWatermark);
  • 绘制它的字体(一个 14 点 Verdana 的 `Font` 对象,粗体);
  • 一个 `SolidBrush` 对象,包含绘制它的颜色(Color.Beige);
  • 另外两个是 X 和 Y 坐标,表示在 `Bitmap(0,0)` 内的绘制的左上角。

有了所有这些信息,我们就可以修改我们的代码块,如下所示,以创建阴影效果:

    'Draw the watermark string onto the Bitmap in Black to create
    'the "shadow", offset 2 pixels from our original position:
    canvas.DrawString(strWatermark, _ 
        New Font("Verdana", 14, FontStyle.Bold), _
        New SolidBrush(Color.Black), 2, 2)
    'Now given that a subsequent call to .DrawString will draw on
    '*top* of our previous text, we'll draw the watermark string onto 
    'the Bitmap again, this time in White, at the original position:
    canvas.DrawString(strWatermark, _ 
        New Font("Verdana", 14, FontStyle.Bold), _
        New SolidBrush(Color.White), 0, 0)

正如您在代码注释中所看到的,我们所做的只是将相同的文本绘制两次——先是黑色,然后是白色——并带有 2 像素的偏移。最终结果是我们的白色文本带有 2 像素的黑色阴影,这就引出了……

第四步:使文本透明

有很多方法可以解决问题,在 GDI+ 中使文本透明也不例外。为了扩展我对 .NET 中图形函数的熟悉程度,我尝试了三种不同的方法,然后选择了一种。简而言之,这些方法是:

次优方法 #1:在 Graphics.DrawString 方法中使用替代画笔

`Graphics` 类的 `DrawString` 方法接受任何派生自 `Brush` 的类作为第三个参数,包括 `SolidBrush`、`TextureBrush` 和 `HatchBrush`。尝试这些方法并没有太久就发现它们不能直接解决透明度问题。这些画笔在创建其他效果时非常有用,而且在紧急情况下,我*可以*使用纹理画笔来实现伪透明效果,但这并不是我在这里需要的功能的明显答案。

次优方法 #2:“绿屏”方法

Vb-Helper.com 的一篇文章提出了另一种方法,它包括:

  • 创建一个只有文本或水印的辅助 `Bitmap`;
  • 使这个辅助 `Bitmap` 的背景色透明;
  • 逐像素地循环遍历辅助 `Bitmap`,通过将 ALPHA 分量设置为 128(50% 不透明度)来操作像素的透明度,最后
  • 使用 `Bitmap` 的 `.DrawImage` 方法将修改后的辅助 `Bitmap` 绘制到原始图片上。

这种方法确实达到了我想要的效果。唯一的问题是我在字母边缘看到了一些锯齿,而逐像素位图操作的性能显然是不可接受的。然而,那篇文章中的技术确实揭示了 .NET 的 GDI 类的一些特性,这些特性引导我找到了最终的解决方案。让我们来看看那种方法,特别是处理像素 ALPHA 分量的代码部分。

    ' Set the watermark's pixels' Alpha components.
    Const ALPHA As Byte = 128
    Dim clr As Color
    For py As Integer = 0 To watermark_bm.Height - 1
        For px As Integer = 0 To watermark_bm.Width - 1
            clr = watermark_bm.GetPixel(px, py)
            watermark_bm.SetPixel(px, py, _
                Color.FromArgb(ALPHA, clr.R, clr.G, clr.B))
        Next px
    Next py

这里的关键是 `Bitmap` 对象的 `GetPixel` 和 `SetPixel` 方法,但真正有趣的是 `Color.FromArgb` 函数。这段代码使用 `GetPixel` 获取图像中每个像素的值,然后通过调用 `SetPixel` 并传递一个由以下元素构造的颜色参数来降低该像素的透明度:

  • ALPHA 分量为 128(或 50% 不透明度);
  • 像素的原始 R、G 和 B 值。

这就是我灵光一现的地方;如果我早知道 `Color` 对象可以以透明度感知的方式构造,我就会直接那样做了。事后诸葛亮,我回到了我原来的代码……

解决方案:跳过所有废话,直接绘制透明文本

看看我调用 `.DrawString` 并需要将 `Color` 对象传递给 `SolidBrush` 对象构造函数的那个地方,现在应该很容易看出需要更改什么。在我之前使用 `Color.White` 和 `Color.Black` 创建 `SolidBrush` 对象的地方,现在可以通过使用 `Color.FromArgb` 来构造*半透明*画笔。

    'Draw the watermark string onto the Bitmap in Black to create
    'the "shadow", offset 2 pixels from our original position:
    canvas.DrawString(strWatermark, _ 
        New Font("Verdana", 14, FontStyle.Bold), _
        New SolidBrush(Color.FromArgb(128, 0, 0, 0)), 2, 2)
    'Now given that a subsequent call to .DrawString will draw on
    '*top* of our previous text, we'll draw the watermark string onto 
    'the Bitmap again, this time in White, at the original position:
    canvas.DrawString(strWatermark, _ 
        New Font("Verdana", 14, FontStyle.Bold), _
        New SolidBrush(Color.FromArgb(128, 255, 255, 255)), 0, 0)

问题就这样解决了。与我之前的代码相比,唯一需要更改的地方是粗体显示的。通过一开始就使用半透明颜色构造我的 `SolidBrush` 对象,我可以完全避免实例化第二个位图的所有开销,更不用说重复调用 `GetPixel` 和 `SetPixel` 了。

第五步:调整水印大小

现在剩下要做的就是解决将我们的文本水印缩放到正在应用它的图像的问题。这里的挑战是获取一堆文本,它是基于矢量的,并以点为单位调整大小以适应给定的字体,然后将其相对于位图进行缩放,位图是基于栅格的,并按像素数进行调整。我们之前已经看到了如何轻松地缩放位图,但是由于我刚刚消除了实例化第二个位图的需要,我不想在不先研究其他方法的情况下,就采取将水印放在自己的位图上并将其缩放到 JPG 图像的方案。在文档中搜索了一下,找到了我想要的;`Graphics` 类提供了一个 `MeasureString` 方法,该方法将返回一个 `SizeF` 结构,其中包含用给定字体绘制的给定字符串的像素尺寸。

有了 `MeasureString` 方法,让我们盘点一下我们的变量,看看最直接的实现会是什么。我们的目标是让我们的水印“尺寸合适”,适合它正在处理的 JPG,所以我们现在必须决定那意味着什么。我咨询了算命师、室内设计师、我的星座运势和一本著名的礼仪书,以确定理想的水印尺寸是 JPG 宽度的 50%。您的体验可能有所不同,当然您也应该咨询您自己备受尊敬的消息来源。无论如何,我们将为 `SizeF` 结构、`DesiredWidth` 和我们将要计算的 `Ratio` 添加变量,以及一个字体对象变量,这样我们就无需每次引用它时都创建一个新的 `Font` 对象:。

dim StringSizeF as SizeF, _
    DesiredWidth as Single,
    wmFont as Font,
    RequiredFontSize as Single,
    Ratio as Single
wmFont = New Font("Verdana", 14, FontStyle.Bold)

现在我们已经具备了开始实现一个粗略但高效的尺寸调整逻辑所需的一切。首先,我们将水印的期望宽度计算为我们将要绘制它的 JPG 宽度的 50%(为了简单起见,我假设高度“够用”,而且在绝大多数情况下确实如此)。

DesiredWidth = bmp.Width * .5

接下来,我们找出我们的字符串的像素大小。

StringSizeF = canvas.MeasureString(strWatermark, wmFont)

现在我们知道了我们的字符串绘制出来的大小,例如,在 14 点的 Verdana 中,我们可以通过将我们的 `String` 的 `width`(以像素为单位)除以字体大小(以点为单位)来派生 [字体大小 : 像素宽度] 的 `Ratio`。

Ratio = StringSizeF.Width / wmFont.SizeInPoints

给定这个 `Ratio`,通过简单的推断就可以得到一个以点为单位的字体大小,在相同的字体和 `String` 下,它将产生我们期望的宽度水印。

RequiredFontSize = DesiredWidth / Ratio

完成了!现在我们只需重新初始化 `Font`,将硬编码的“14”替换为 `RequiredFontSize` 变量,我们就可以绘制我们的水印了——它将是透明的、带阴影的,并且尺寸适合正在处理的图像!

wmFont = New Font("Verdana", RequiredFontSize, FontStyle.Bold)
'Draw the watermark string onto the Bitmap in Black to create
'the "shadow", offset 2 pixels from our original position:
canvas.DrawString(strWatermark, _ 
    wmFont, _
    New SolidBrush(Color.FromArgb(128, 0, 0, 0)), 2, 2)
'Now given that a subsequent call to .DrawString will draw on
'*top* of our previous text, we'll draw the watermark string onto 
'the Bitmap again, this time in White, at the original position:
canvas.DrawString(strWatermark, _ 
    wmFont, _
    New SolidBrush(Color.FromArgb(128, 255, 255, 255)), 0, 0)
 bmp.SetResolution(96, 96)

任何练习的完成都需要一个*陷阱*!在用这个算法处理了几千张图片后,我看到水印的大小出现了剧烈变化,这些变化看似无法解释。调试器在这里并没有提供太多帮助,所以我仔细检查了原始图像,寻找可能导致这种情况的任何原因。果然,JPG 文件之间存在不同的 DPI 分辨率。啊哈!正确的做法应该是获取原始 JPG 的 DPI 并相应地处理水印,但是时间已经很晚了,这段代码开始变得非常乏味,所以我通过添加一个调用 `Bitmap.SetResolution(96,96)`*在*所有测量和尺寸调整逻辑*之前*来偷工减料。这达到了预期的效果,即“正常化”了情况,使得水印大小再次始终是输入图像大小的 50%。由于添加此项后没有明显的性能损失,我决定完成了。哦,快乐的一天!

结论

在进行了所有调整后,如果我们把上面的代码串联起来,我们就得到了一个简单、直接且高效的水印图像函数。尽管仍然有很大的自定义和增强空间(例如,让用户选择字体、调整位置、颜色和透明度等),但此时我们已经满足了设计标准,可以安全地去吃晚饭了。在我的实现中,我回过头来添加了代码,将 © 版权符号添加到水印文本的前面。我还稍微调整了 ALPHA 值,最终认为白色文本的透明度(ALPHA = 136)略高于黑色阴影(ALPHA = 120)看起来更好。

回顾一下实现这个功能所付出的努力,值得注意的是,我们需要的一切功能都包含在 .NET 的两个类中——`Bitmap` 对象和 `Graphics` 对象。

快乐水印!

© . All rights reserved.