单色过渡色还原 PNG:从白底结果反推透明通道
目录
1. 问题与目标
2. 还原条件
3. 还原策略
4. 关键算法
4.1 向量投影反推 alpha(结构反解)
4.2 结构融合
4.3 输出像素合成
编辑4.4. 参数说明与调参建议
5. 可视化验证指标
6. 源码
1. 问题与目标
已知一张“单色软边过渡”图像原本来自透明层(例如红色喷笔),后续被合成为白底图。 现在希望反推回“透明 PNG”,并在保持空间结构(层次、扩散范围、边缘平滑度)的前提下,支持色调替换。
核心目标:
- 透明结构层次尽量与原图一致(避免中心被压重或外圈被截断)。
- 支持目标色调实时调整(例如红 → 蓝、红 → 紫)。
- 提供强制对齐选项,使最强点更贴近目标色基调。
2. 还原条件
在 straight-alpha 模型下,白底合成可写为:
C = α·F + (1-α)·W
其中:C为观测颜色,F为前景颜色,W为已知底色(白),α为透明度。 单像素共有 3 个方程(RGB),未知数 4 个(F 的三个通道 + α),因此一般不可唯一反解。
严格可逆的典型条件:
- 底色
W已知; - 前景色
F可认为恒定(或已知函数)。
结论:若前景颜色本身随空间变化,仅凭一张白底结果图无法无损恢复原始 (F, α)。工程上要“尽量像”,必须引入先验与约束。
3. 还原策略
本文实现采用两阶段:结构优先 + 色调重映射
| 阶段 | 目标 | 说明 |
|---|---|---|
| 阶段 A:反推结构 | 还原 alpha 空间层次 | 从白底图直接估计每像素透明度,优先保持扩散范围与层次分布。 |
| 阶段 B:色调映射 | 替换到目标色系 | 在保留结构的基础上,把颜色映射到目标色;支持“强制对齐”增强中心色匹配。 |
“结构优先”:
若直接做 RGB 调色或 alpha 拉伸,容易出现“中心偏重、外圈变薄”的视觉偏差。 结构优先策略先锁定空间分布,再做色调替换,能更好贴近原图组织方式。
4. 关键算法
4.1 向量投影反推 alpha(结构反解)
对于目标色F_t,白底W,观测像素C:
a = dot(W-C, W-F_t) / ||W-F_t||²
并裁剪到[0,1]。这相当于在最小二乘意义下估计该像素对目标色方向的“浓度”。
4.2 结构融合
将“基础反解 alpha”与“投影 alpha”按比例融合,抑制噪点并保持层次:
a = forceAlign ? aProj : lerp(aBase, aProj, 0.72)
其中forceAlign为“强制对齐目标色”模式。
4.3 输出像素合成
输出采用:
A = round(a * 255) RGB = lerp(baseTone, targetTone, toneMix) out = Color.FromArgb(A, RGB)
放入“alpha 热力图 + 径向剖面曲线(原图 vs 还原)”,
这样可以在同一透明结构上连续变换色调,且实时响应 UI 参数调节。
![]()
4.4. 参数说明与调参建议
| 参数 | 范围 | 作用 | 建议 |
|---|---|---|---|
targetTone | RGB | 目标色基调 | 先选接近期望中心色,再调强度。 |
toneMix | 0~1 | 色调替换强度 | 常用 0.65~0.9;1.0 为近似完全替换。 |
forceAlign | bool | 是否强制对齐目标色 | 中心偏灰时开启;结构过硬时关闭。 |
| 融合系数 | 0~1 | lerp(aBase, aProj, k) | k越大越依赖结构投影,越小越平稳。 |
5. 可视化验证指标
- 径向均匀性:等距半径上亮度/饱和度变化是否平滑。
- 中心-外圈比例:中心峰值与半高宽是否接近目标图。
- 色调一致性:中心区域 HSV 与目标色偏差(ΔH、ΔS、ΔV)。
- 结构保真:对比还原前后 alpha 热力图(或灰度图)。
6. 源码
核心算法源码:
public static class SingleColorTransBackProcessor { public static SingleColorTransBackResult Process(Bitmap flatOnWhite, SingleColorTransBackOptions options) { if (flatOnWhite == null) throw new ArgumentNullException(nameof(flatOnWhite)); if (options == null) throw new ArgumentNullException(nameof(options)); var baseRecovered = RecoverBitmapFromBackground(flatOnWhite, options.KnownForeground, options.Background); var preview = BuildStructurePreservingPreview(flatOnWhite, baseRecovered, options); return new SingleColorTransBackResult(baseRecovered, preview); } public static Bitmap BuildPreview(Bitmap flatOnWhite, Bitmap baseRecovered, SingleColorTransBackOptions options) { if (flatOnWhite == null) throw new ArgumentNullException(nameof(flatOnWhite)); if (options == null) throw new ArgumentNullException(nameof(options)); return BuildStructurePreservingPreview(flatOnWhite, baseRecovered, options); } private static Bitmap RecoverBitmapFromBackground(Bitmap source, Color knownForeground, Color background) { var lb = new LockBitmap(source); lb.LockBits(); try { var dst = new Bitmap(lb.Width, lb.Height, PixelFormat.Format32bppArgb); for (var y = 0; y < lb.Height; y++) { for (var x = 0; x < lb.Width; x++) { var c = lb.GetPixel(x, y); var o = RecoverStraightAlpha(c, knownForeground, background); dst.SetPixel(x, y, o); } } return dst; } finally { lb.UnlockBits(); } } private static Color RecoverStraightAlpha(Color c, Color f, Color w) { var a1 = SolveAlpha(c.R, f.R, w.R); var a2 = SolveAlpha(c.G, f.G, w.G); var a3 = SolveAlpha(c.B, f.B, w.B); var a = MedianNonNaN(a1, a2, a3); if (double.IsNaN(a)) a = 0.0; a = Clamp01(a); var ab = (byte)Math.Round(a * 255.0); return Color.FromArgb(ab, f.R, f.G, f.B); } private static Bitmap BuildStructurePreservingPreview(Bitmap flatOnWhite, Bitmap baseRecovered, SingleColorTransBackOptions options) { var toneMix = Clamp01(options.ToneMix); var structureBlend = Clamp01(options.StructureBlend); var w = flatOnWhite.Width; var h = flatOnWhite.Height; var dst = new Bitmap(w, h, PixelFormat.Format32bppArgb); var white = new[] { 255.0, 255.0, 255.0 }; var baseTone = new[] { (double)options.KnownForeground.R, (double)options.KnownForeground.G, (double)options.KnownForeground.B }; var target = new[] { (double)options.TargetTone.R, (double)options.TargetTone.G, (double)options.TargetTone.B }; var toneNow = new[] { Lerp(baseTone[0], target[0], toneMix), Lerp(baseTone[1], target[1], toneMix), Lerp(baseTone[2], target[2], toneMix) }; var vf = new[] { white[0] - toneNow[0], white[1] - toneNow[1], white[2] - toneNow[2] }; var denom = vf[0] * vf[0] + vf[1] * vf[1] + vf[2] * vf[2]; if (denom < 1e-6) denom = 1.0; for (var y = 0; y < h; y++) { for (var x = 0; x < w; x++) { var c = flatOnWhite.GetPixel(x, y); var wc = new[] { white[0] - c.R, white[1] - c.G, white[2] - c.B }; var aProj = (wc[0] * vf[0] + wc[1] * vf[1] + wc[2] * vf[2]) / denom; aProj = Clamp01(aProj); var aBase = baseRecovered != null ? baseRecovered.GetPixel(x, y).A / 255.0 : aProj; var a = options.ForceAlignTone ? aProj : Lerp(aBase, aProj, structureBlend); var an = (int)Math.Round(Clamp01(a) * 255.0); var r = ClampByte((int)Math.Round(toneNow[0])); var g = ClampByte((int)Math.Round(toneNow[1])); var b = ClampByte((int)Math.Round(toneNow[2])); dst.SetPixel(x, y, Color.FromArgb(an, r, g, b)); } } return dst; } private static double SolveAlpha(byte c, byte f, byte w) { var den = f - w; if (Math.Abs(den) < 1e-6) return double.NaN; return (c - w) / (double)den; } private static double MedianNonNaN(double a, double b, double c) { var arr = new System.Collections.Generic.List<double>(3); if (!double.IsNaN(a) && !double.IsInfinity(a)) arr.Add(a); if (!double.IsNaN(b) && !double.IsInfinity(b)) arr.Add(b); if (!double.IsNaN(c) && !double.IsInfinity(c)) arr.Add(c); if (arr.Count == 0) return double.NaN; arr.Sort(); return arr[arr.Count / 2]; } private static int ClampByte(int v) => v < 0 ? 0 : (v > 255 ? 255 : v); private static double Clamp01(double v) => v < 0 ? 0 : (v > 1 ? 1 : v); private static double Lerp(double a, double b, double t) => a + (b - a) * t; }在不同透明背景下,我们可以更换任意色调:
完整Demo源码:https://download.csdn.net/download/LateFrames/92830783?spm=1001.2014.3001.5501
