立開(kāi)發(fā)看圖工具 PixSight 經(jīng)驗(yàn)介紹(二)—— 水印模塊開(kāi)發(fā)實(shí)錄)
1. 前言上篇《C# WPF 獨(dú)立開(kāi)發(fā)看圖工具 PixSight 經(jīng)驗(yàn)介紹》介紹了整體技術(shù)架構(gòu)、圖片瀏覽/查看/編輯/OCR/格式轉(zhuǎn)換以及幾個(gè)最耗時(shí)的性能坑大圖內(nèi)存溢出、多格式兼容、啟動(dòng)速度、2000 圖片假死、OCR 語(yǔ)言選擇等。原文鏈接https://blog.csdn.net/weixin_53483426/article/details/162941010本文作為續(xù)篇記錄最近新增的核心模塊——「添加水印」。需求很樸素日常要把證件、材料照片發(fā)給別人時(shí)最怕被拿去亂用于是需要給圖片批量鋪上平鋪文字水印含用途、限制等且要所見(jiàn)即所得、能一次處理一堆圖。下面就從功能、原理到開(kāi)發(fā)踩坑完整講一遍重點(diǎn)依然是那些“文檔里搜不到、只能自己踩出來(lái)”的坑。2. 功能介紹入口在主界面的「添加水印」。典型流程添加圖片支持對(duì)話框多選也可從瀏覽視圖直接選擇帶入→ 設(shè)置水印參數(shù) →實(shí)時(shí)預(yù)覽 → 批量保存到各自原目錄同名自動(dòng)加序號(hào)不覆蓋原圖。面向普通用戶做了幾處易用性處理字體下拉顯示中文名微軟雅黑 / 黑體 / 宋體 / 楷體 / 微軟雅黑 UI…內(nèi)部再映射到真正的字體名普通用戶無(wú)需懂英文/拼音。顏色既可用預(yù)設(shè)色板紅橙黃綠青藍(lán)紫黑白灰也能點(diǎn)「取色」打開(kāi)系統(tǒng)取色器完全不需要了解十六進(jìn)制。參數(shù)改完自動(dòng)持久化下次進(jìn)入沿用上次設(shè)定。授權(quán)添加水印累計(jì) 50 次整批成功計(jì) 1 次足夠日常使用超出后可購(gòu)買激活碼解鎖。默認(rèn)參數(shù)WatermarkOptions參數(shù)默認(rèn)值說(shuō)明水印文字Text空必填整段平鋪文字例如“僅限辦理XX業(yè)務(wù)使用”字體FontFamilyMicrosoft YaHei下拉顯示中文名渲染用真實(shí)字體名字號(hào)FontSize00 按圖寬自適應(yīng)字號(hào) 圖寬 / 27顏色ColorHex紅色#FF0000可用取色器任意選不透明度Opacity45≈ alpha 115/255半透明不擋內(nèi)容角度Angle30°整數(shù)度負(fù)值向左上傾斜行數(shù)Rows6縱向平鋪密度參考常見(jiàn)證件照水印后綴OutputSuffix_水印輸出文件名附加如 photo_水印.jpg3. 水印原理平鋪整段文字而不是單字很多人以為“平鋪水印”是讓每個(gè)小瓦片只寫一個(gè)字其實(shí)不是。本項(xiàng)目每個(gè)瓦片寫的就是整段水印文字比如“僅限辦理業(yè)務(wù)使用”。做法是先算出單個(gè)瓦片的尺寸文字寬 × 行高把整段文字柵格化到一張小透明瓦片上字體只柵格化一次把這張小瓦片整體旋轉(zhuǎn)到目標(biāo)角度只旋轉(zhuǎn)一次然后在源圖上按“旋轉(zhuǎn)后的網(wǎng)格”逐塊 Composite 這張小瓦片鋪滿整張圖。因?yàn)樾D(zhuǎn)是線性變換逐塊旋轉(zhuǎn)放置等價(jià)于“先把整層旋轉(zhuǎn)好再和原圖合成”視覺(jué)上完全一致。預(yù)覽圖會(huì)先把長(zhǎng)邊限制到 1280px 降采樣避免超大圖旋轉(zhuǎn)帶來(lái)的內(nèi)存/性能問(wèn)題。4. 開(kāi)發(fā)踩坑總結(jié)4.1 超大旋轉(zhuǎn)畫布的性能災(zāi)難最耗時(shí)的一坑最初的直觀寫法建一張覆蓋整張圖的巨大透明層 → 在上面寫好所有文字 → 把整層旋轉(zhuǎn) → 裁切 → 放大回原圖尺寸 → 再和原圖整體合成。問(wèn)題在水印是斜鋪的要覆蓋原圖四角這張“巨大透明層”必須比原圖大不少約 1.4 倍對(duì)角線。在 6000×4000 原圖上這一步要 6~11 秒/張批量幾百?gòu)埜緵](méi)法用。新方案關(guān)鍵思路完全不創(chuàng)建超大畫布、不做放大上采樣。文字柵格化到一張幾十~幾百像素的小瓦片 → 旋轉(zhuǎn)瓦片一次 → 直接在源圖上按“旋轉(zhuǎn)后的網(wǎng)格”逐塊合成小瓦片一張圖約 100 次廉價(jià)操作。速度提升一個(gè)數(shù)量級(jí)以上效果肉眼無(wú)差別。核心網(wǎng)格合成代碼如下// 直接在源圖上按“旋轉(zhuǎn)后的網(wǎng)格”逐塊合成小瓦片不再建超大畫布 // 旋轉(zhuǎn)矩陣與 Magick Rotate(angle) 一致圖像坐標(biāo)系 y 向下正角順時(shí)針 // 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; // 覆蓋范圍取旋轉(zhuǎn)后外接矩形在網(wǎng)格空間的半幅 余量確保整圖被覆蓋 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) 想畫純紅結(jié)果出來(lái)是暗紅肉眼都能看出不對(duì)。原因是本項(xiàng)目用的是 Q16 版本16 位色深Quantum 范圍 0~65535而 new MagickColor(byte,byte,byte) 這個(gè)構(gòu)造函數(shù)會(huì)把 0~255 當(dāng)作 Quantum 的“小值”直接填進(jìn)去沒(méi)有映射到 Quantum 全范圍于是紅色被壓到很暗。修復(fù)用 MagickColor.FromRgb(r, g, b)它會(huì)正確地把 0~255 映射到 Quantum 全范圍。同理不透明度也統(tǒng)一在合成水印層時(shí)用 Evaluate(Channels.Alpha, Multiply, alpha) 處理保證整層一致// Q16 下 new MagickColor(byte,byte,byte) 會(huì)把 255 當(dāng)成 Quantum 小值導(dǎo)致顏色極暗 // 必須用 FromRgb 才能將 0-255 正確映射到 Quantum 全范圍。 var opaqueColor MagickColor.FromRgb(r, g, b); // 在小瓦片上統(tǒng)一處理不透明度平鋪后整體一致 double alphaFactor opacity / 100.0; if (alphaFactor 1.0) tile.Evaluate(Channels.Alpha, EvaluateOperator.Multiply, alphaFactor);4.3 中文與字體名處理ImageMagick 不吃「微軟雅黑」這種中文顯示名它要么認(rèn)字體名Microsoft YaHei要么認(rèn)字體文件路徑。另外反復(fù)枚舉 MagickNET.FontFamilies 會(huì)觸發(fā)字體緩存重建偶發(fā)卡頓甚至異常。本項(xiàng)目的解法下拉用 FontOption 類Name 顯示中文名給用戶看Family 存真實(shí)字體名供渲染選中后再把 Family 寫回渲染參數(shù)。ResolveFont 優(yōu)先到 Windows\Fonts 找字體文件路徑微軟雅黑→msyh.ttc[0]、黑體→simhei.ttf 等繞過(guò) ImageMagick 的字體名緩存都找不到才回退到字體名字符串。字體列表用 Lazy 只枚舉一次避免反復(fù)觸發(fā)緩存重建。// 字體選項(xiàng)Name中文顯示名給用戶看Family真實(shí)字體名供渲染 public class FontOption { public string Name { get; set; } public string Family { get; set; } } // 在 Windows 字體目錄中查找字體文件優(yōu)先 .ttf再 .ttc/.otf // .ttc 集合字體加 [0] 幫助 ImageMagick 選第一個(gè) 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 預(yù)覽與保存分離 防抖/取消預(yù)覽RenderPreview不寫盤且長(zhǎng)邊限 1280px 降采樣保存ApplyAndSave才真正落盤。難點(diǎn)在于拖動(dòng)滑塊時(shí)如果每次都重渲染隊(duì)列會(huì)堆積導(dǎo)致界面假死。處理手段參數(shù)變更走 200ms 防抖停手后才重建一次預(yù)覽并后臺(tái)持久化默認(rèn)參數(shù)預(yù)覽在后臺(tái) Task.Run 渲染每次請(qǐng)求帶遞增 token舊的請(qǐng)求完成后若 token 已過(guò)期就丟棄結(jié)果避免亂序/堆積角度/不透明度/行數(shù)這類滑塊拖動(dòng)過(guò)程中只更新數(shù)字松手CommitSliderChange才觸發(fā)一次預(yù)覽重建避免持續(xù)重渲染。// 防抖停手 200ms 后才重建預(yù)覽 持久化默認(rèn)參數(shù) _debounceTimer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(200) }; _debounceTimer.Tick (s, e) { _debounceTimer.Stop(); RebuildPreview(); Task.Run(() PersistDefaults()); }; // 重建預(yù)覽后臺(tái)渲染 token 取消陳舊任務(wù)避免隊(duì)列堆積假死 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; // 已被新請(qǐng)求接管丟棄 } Application.Current?.Dispatcher?.Invoke(() { if (cts.IsCancellationRequested) return; if (bmp ! null) PreviewImage bmp; else StatusText $預(yù)覽失敗{error}; }); }, cts.Token); }4.5 批量保存的并發(fā)與授權(quán)額度保存階段逐張并行限制并發(fā) 3既利用多核又避免內(nèi)存峰值過(guò)高M(jìn)agickImage 各實(shí)例獨(dú)立、線程安全。原格式不可寫水印時(shí)自動(dòng)回退 PNG/JPG。授權(quán)額度按“整批”計(jì)只有整批全部成功才扣 1 次中途失敗不扣符合用戶預(yù)期。4.6 順帶填的坑清理 ImageMagick 臨時(shí)文件Magick.NET 處理超大圖時(shí)像素緩存可能溢出到 %TEMP% 的 magick-* 文件無(wú)擴(kuò)展名。程序正常退出會(huì)釋放但若強(qiáng)關(guān)/中斷這些文件會(huì)遺留——曾一次堆積 40GB 占滿系統(tǒng)盤。于是在 App 啟動(dòng)時(shí)加了一段自清理刪除「最后寫入早于 5 分鐘前、且當(dāng)前沒(méi)被占用獨(dú)占打開(kāi)失敗」的孤兒文件單文件失敗跳過(guò)、整體異常兜底絕不阻塞啟動(dòng)。5. 軟件獲取與授權(quán)更新本工具提供 30 天免費(fèi)試用正式授權(quán)為一次性買斷無(wú)年費(fèi)正式版與試用版是同一程序首次運(yùn)行即 30 天試用激活后轉(zhuǎn)為正式版。相比上篇授權(quán)項(xiàng)增加「添加水印累計(jì) 50 次」整批成功計(jì) 1 次。激活采用「機(jī)器碼 激活碼」方式一臺(tái)機(jī)器對(duì)應(yīng)一個(gè)授權(quán)便于離線使用。獲取方式請(qǐng)關(guān)注作者后續(xù)動(dòng)態(tài)同前篇網(wǎng)盤鏈接。6. 結(jié)尾水印模塊從“想要”到“好用”最值錢的經(jīng)驗(yàn)是平鋪水印不要建超大旋轉(zhuǎn)畫布改成「小瓦片旋轉(zhuǎn)一次 網(wǎng)格逐塊合成」以及 Magick.NET Q16 下務(wù)必用 FromRgb 設(shè)色。后續(xù)會(huì)持續(xù)更新文章、迭代功能歡迎開(kāi)發(fā)同行交流 WPF 圖像開(kāi)發(fā)相關(guān)問(wèn)題。