C# WPF 独立开发看图工具 PixSight 经验介绍(二)—— 水印模块开发实录

1. 前言

上篇《C# WPF 独立开发看图工具 PixSight 经验介绍》介绍了整体技术架构、图片浏览/查看/编辑/OCR/格式转换,以及几个最耗时的性能坑(大图内存溢出、多格式兼容、启动速度、2000+ 图片假死、OCR 语言选择等)。

原文链接:https://blog.csdn.net/weixin_53483426/article/details/162941010

本文作为续篇,记录最近新增的核心模块------「添加水印」。需求很朴素:日常要把证件、材料照片发给别人时,最怕被拿去乱用,于是需要给图片批量铺上平铺文字水印(含用途、限制等),且要所见即所得、能一次处理一堆图。下面就从功能、原理到开发踩坑完整讲一遍,重点依然是那些"文档里搜不到、只能自己踩出来"的坑。

2. 功能介绍

入口在主界面的「添加水印」。典型流程:添加图片(支持对话框多选,也可从浏览视图直接选择带入)→ 设置水印参数 →实时预览 → 批量保存到各自原目录(同名自动加序号,不覆盖原图)。

面向普通用户做了几处易用性处理:

  • 字体下拉显示中文名(微软雅黑 / 黑体 / 宋体 / 楷体 / 微软雅黑 UI...),内部再映射到真正的字体名,普通用户无需懂英文/拼音。
  • 颜色既可用预设色板(红橙黄绿青蓝紫黑白灰),也能点「取色」打开系统取色器,完全不需要了解十六进制。
  • 参数改完自动持久化,下次进入沿用上次设定。
  • 授权:添加水印累计 50 次(整批成功计 1 次),足够日常使用;超出后可购买激活码解锁。

默认参数(WatermarkOptions):

|-------------------------|-----------------|-------------------------|
| 参数 | 默认值 | 说明 |
| 水印文字 Text | 空(必填) | 整段平铺文字,例如"仅限办理XX业务使用" |
| 字体 FontFamily | Microsoft YaHei | 下拉显示中文名,渲染用真实字体名 |
| 字号 FontSize | 0 | 0 = 按图宽自适应,字号 = 图宽 / 27 |
| 颜色 ColorHex | 红色 | (#FF0000)可用取色器任意选 |
| 不透明度 Opacity | 45 | ≈ alpha 115/255,半透明不挡内容 |
| 角度 Angle | 30° | 整数度;负值向左上倾斜 |
| 行数 Rows | 6 | 纵向平铺密度,参考常见证件照水印 |
| 后缀 OutputSuffix | _水印 | 输出文件名附加,如 photo_水印.jpg |

3. 水印原理:平铺整段文字,而不是单字

很多人以为"平铺水印"是让每个小瓦片只写一个字,其实不是。本项目每个瓦片写的就是整段水印文字(比如"仅限办理业务使用")。做法是:

  • 先算出单个瓦片的尺寸(文字宽 × 行高),把整段文字栅格化到一张小透明瓦片上(字体只栅格化一次);
  • 把这张小瓦片整体旋转到目标角度(只旋转一次);
  • 然后在源图上,按"旋转后的网格"逐块 Composite 这张小瓦片,铺满整张图。

因为旋转是线性变换,逐块旋转放置等价于"先把整层旋转好再和原图合成",视觉上完全一致。预览图会先把长边限制到 1280px 降采样,避免超大图旋转带来的内存/性能问题。

4. 开发踩坑总结

4.1 超大旋转画布的性能灾难(最耗时的一坑)

最初的直观写法:建一张覆盖整张图的巨大透明层 → 在上面写好所有文字 → 把整层旋转 → 裁切 → 放大回原图尺寸 → 再和原图整体合成。问题在水印是斜铺的,要覆盖原图四角,这张"巨大透明层"必须比原图大不少(约 1.4 倍对角线)。在 6000×4000 原图上,这一步要 6~11 秒/张,批量几百张根本没法用。

新方案(关键思路):完全不创建超大画布、不做放大上采样。文字栅格化到一张几十~几百像素的小瓦片 → 旋转瓦片一次 → 直接在源图上按"旋转后的网格"逐块合成小瓦片(一张图约 100 次廉价操作)。速度提升一个数量级以上,效果肉眼无差别。核心网格合成代码如下:

cs 复制代码
// 直接在源图上按"旋转后的网格"逐块合成小瓦片(不再建超大画布)
// 旋转矩阵与 Magick Rotate(angle) 一致(图像坐标系 y 向下,正角顺时针):
//   x' = cx + ux·cosθ - uy·sinθ
//   y' = cy + ux·sinθ + uy·cosθ
double rad = angle * Math.PI / 180.0;
double cosR = Math.Cos(rad);
double sinR = Math.Sin(rad);
int tw = (int)rotated.Width, th = (int)rotated.Height;
double cx = W / 2.0, cy = H / 2.0;

// 覆盖范围:取旋转后外接矩形在网格空间的半幅 + 余量,确保整图被覆盖
double absCos = Math.Abs(cosR), absSin = Math.Abs(sinR);
int halfX = (int)((W * absCos + H * absSin) / 2.0) + stepX;
int halfY = (int)((W * absSin + H * absCos) / 2.0) + stepY;
int nx = (int)(halfX / (double)stepX) + 1;
int ny = (int)(halfY / (double)stepY) + 1;

for (int ix = -nx; ix <= nx; ix++)
{
    for (int iy = -ny; iy <= ny; iy++)
    {
        double ux = ix * stepX, uy = iy * stepY;
        double rx = ux * cosR - uy * sinR;
        double ry = ux * sinR + uy * cosR;
        int px = (int)(cx + rx - tw / 2.0);
        int py = (int)(cy + ry - th / 2.0);
        src.Composite(rotated, px, py, CompositeOperator.Over);
    }
}

4.2 Magick.NET Q16 下颜色变"暗红"的坑

用 new MagickColor((byte)255, 0, 0) 想画纯红,结果出来是暗红,肉眼都能看出不对。原因是本项目用的是 Q16 版本(16 位色深,Quantum 范围 0~65535),而 new MagickColor(byte,byte,byte) 这个构造函数会把 0~255 当作 Quantum 的"小值"直接填进去,没有映射到 Quantum 全范围,于是红色被压到很暗。

修复:用 MagickColor.FromRgb(r, g, b),它会正确地把 0~255 映射到 Quantum 全范围。同理不透明度也统一在合成水印层时用 Evaluate(Channels.Alpha, Multiply, alpha) 处理,保证整层一致:

cs 复制代码
// Q16 下 new MagickColor(byte,byte,byte) 会把 255 当成 Quantum 小值,导致颜色极暗,
// 必须用 FromRgb 才能将 0-255 正确映射到 Quantum 全范围。
var opaqueColor = MagickColor.FromRgb(r, g, b);

// 在小瓦片上统一处理不透明度,平铺后整体一致
double alphaFactor = opacity / 100.0;
if (alphaFactor < 1.0)
    tile.Evaluate(Channels.Alpha, EvaluateOperator.Multiply, alphaFactor);

4.3 中文与字体名处理

ImageMagick 不吃「微软雅黑」这种中文显示名,它要么认字体名(Microsoft YaHei),要么认字体文件路径。另外反复枚举 MagickNET.FontFamilies 会触发字体缓存重建,偶发卡顿甚至异常。本项目的解法:

  • 下拉用 FontOption 类:Name 显示中文名给用户看,Family 存真实字体名供渲染;选中后再把 Family 写回渲染参数。
  • ResolveFont 优先到 Windows\Fonts 找字体文件路径(微软雅黑→msyh.ttc0、黑体→simhei.ttf 等),绕过 ImageMagick 的字体名缓存;都找不到才回退到字体名字符串。
  • 字体列表用 Lazy<> 只枚举一次,避免反复触发缓存重建。
cs 复制代码
// 字体选项:Name=中文显示名(给用户看),Family=真实字体名(供渲染)
public class FontOption
{
    public string Name { get; set; }
    public string Family { get; set; }
}

// 在 Windows 字体目录中查找字体文件,优先 .ttf,再 .ttc/.otf
// (.ttc 集合字体加 [0] 帮助 ImageMagick 选第一个)
private static string FindFontFile(params string[] names)
{
    string fontsDir = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Fonts");
    foreach (var name in names)
    {
        string lower = name.ToLowerInvariant();
        string[] candidates;
        if (lower.Contains("yahei") || lower.Contains("微软雅黑"))
            candidates = new[] { "msyh.ttc", "msyh.ttc[0]", "msyhbd.ttc", "msyhl.ttc" };
        else if (lower.Contains("heiti") || lower == "simhei")
            candidates = new[] { "simhei.ttf", "simhei.ttc", "simhei.ttc[0]" };
        else if (lower.Contains("sun") || lower == "simsun")
            candidates = new[] { "simsun.ttc", "simsun.ttc[0]", "simsunb.ttf" };
        else if (lower.Contains("kai") || lower == "kaiti")
            candidates = new[] { "simkai.ttf" };
        else if (lower == "arial")
            candidates = new[] { "arial.ttf" };
        else if (lower == "times new roman")
            candidates = new[] { "times.ttf", "timesbd.ttf" };
        else
            candidates = new[] { $"{lower}.ttf", $"{lower}.ttc[0]", $"{lower}.ttc", $"{lower}.otf" };

        foreach (var c in candidates)
        {
            string raw = c.Contains("[") ? c.Substring(0, c.IndexOf('[')) : c;
            string full = Path.Combine(fontsDir, raw);
            if (File.Exists(full))
                return Path.Combine(fontsDir, c);
        }
    }
    return null;
}

4.4 预览与保存分离 + 防抖/取消

预览(RenderPreview)不写盘,且长边限 1280px 降采样;保存(ApplyAndSave)才真正落盘。难点在于拖动滑块时如果每次都重渲染,队列会堆积导致界面假死。处理手段:

  • 参数变更走 200ms 防抖:停手后才重建一次预览,并后台持久化默认参数;
  • 预览在后台 Task.Run 渲染,每次请求带递增 token,旧的请求完成后若 token 已过期就丢弃结果,避免乱序/堆积;
  • 角度/不透明度/行数这类滑块,拖动过程中只更新数字,松手(CommitSliderChange)才触发一次预览重建,避免持续重渲染。
cs 复制代码
// 防抖:停手 200ms 后才重建预览 + 持久化默认参数
_debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) };
_debounceTimer.Tick += (s, e) =>
{
    _debounceTimer.Stop();
    RebuildPreview();
    Task.Run(() => PersistDefaults());
};

// 重建预览:后台渲染 + token 取消陈旧任务,避免队列堆积假死
private void RebuildPreview()
{
    var item = CurrentItem;
    if (item == null) { PreviewImage = null; return; }

    _previewCts?.Cancel();                       // 取消上一次
    _previewCts = new CancellationTokenSource();
    var cts = _previewCts;
    int token = ++_previewToken;                // 递增 token

    Task.Run(() =>
    {
        var (bmp, error) = ServiceLocator.Watermark.RenderPreview(item.SourcePath, opt);
        lock (_previewLock)
        {
            if (token != _previewToken) return;  // 已被新请求接管,丢弃
        }
        Application.Current?.Dispatcher?.Invoke(() =>
        {
            if (cts.IsCancellationRequested) return;
            if (bmp != null) PreviewImage = bmp;
            else StatusText = $"预览失败:{error}";
        });
    }, cts.Token);
}

4.5 批量保存的并发与授权额度

保存阶段逐张并行(限制并发 3,既利用多核又避免内存峰值过高;MagickImage 各实例独立、线程安全)。原格式不可写水印时自动回退 PNG/JPG。授权额度按"整批"计:只有整批全部成功才扣 1 次,中途失败不扣,符合用户预期。

4.6 顺带填的坑:清理 ImageMagick 临时文件

Magick.NET 处理超大图时,像素缓存可能溢出到 %TEMP% 的 magick-* 文件(无扩展名)。程序正常退出会释放,但若强关/中断,这些文件会遗留------曾一次堆积 40GB+ 占满系统盘。于是在 App 启动时加了一段自清理:删除「最后写入早于 5 分钟前、且当前没被占用(独占打开失败)」的孤儿文件,单文件失败跳过、整体异常兜底,绝不阻塞启动。

5. 软件获取与授权(更新)

本工具提供 30 天免费试用,正式授权为一次性买断(无年费);正式版与试用版是同一程序,首次运行即 30 天试用,激活后转为正式版。相比上篇,授权项增加「添加水印累计 50 次」(整批成功计 1 次)。

激活采用「机器码 + 激活码」方式,一台机器对应一个授权,便于离线使用。获取方式请关注作者后续动态(同前篇网盘链接)。

6. 结尾

水印模块从"想要"到"好用",最值钱的经验是:平铺水印不要建超大旋转画布,改成「小瓦片旋转一次 + 网格逐块合成」;以及 Magick.NET Q16 下务必用 FromRgb 设色。后续会持续更新文章、迭代功能,欢迎开发同行交流 WPF 图像开发相关问题。

相关推荐
hez20107 小时前
.NET 11 Runtime Async 详解
c#·.net·.net core
猿长大人8 小时前
C# | Serilog 新手入门
开发语言·c#·.net·log
在世修行10 小时前
从零打造 C# 工业视觉检测系统(七):串口通信 SerialPort 封装与开发
开发语言·c#·modbus rtu·rs232/rs485
NoteStream14 小时前
【C语言基础】分支和循环(上)
c语言·开发语言·c++·经验分享·笔记·算法·c#
jufeng130715 小时前
【系列:MiniKV 原理剖析 · 第 6 篇】
linux·软件工程·个人开发·makefile
lzhdim17 小时前
C# 通过 Windows API 实现进程内存读写操作
开发语言·windows·c#
-银雾鸢尾-17 小时前
C#中的预处理指令
开发语言·c#
dalong1017 小时前
Savitzky-Golay 的C# 实现
开发语言·kotlin·c#
weixin_4684668518 小时前
目标检测精度上限与影响因素分析
图像处理·人工智能·目标检测·计算机视觉·图像分类·coco·检测精度
猿长大人18 小时前
C# | Autofac 新手指南:从零理解依赖注入与组件装配
c#·.net·di·依赖注入