单色过渡色还原 PNG:从白底结果反推透明通道

1

  1. 问题与目标
    已知一张“单色软边过渡”图像原本来自透明层(例如红色喷笔),后续被合成为白底图。 现在希望反推回“透明 PNG”,并在保持空间结构(层次、扩散范围、边缘平滑度)的前提下,支持色调替换。

核心目标:

透明结构层次尽量与原图一致(避免中心被压重或外圈被截断)。
支持目标色调实时调整(例如红 → 蓝、红 → 紫)。
提供强制对齐选项,使最强点更贴近目标色基调。
2. 还原条件
在 straight-alpha 模型下,白底合成可写为:

C = α·F + (1-α)·W

其中:C 为观测颜色,F 为前景颜色,W 为已知底色(白),α 为透明度。 单像素共有 3 个方程(RGB),未知数 4 个(F 的三个通道 + α),因此一般不可唯一反解。

严格可逆的典型条件:

底色 W 已知;
前景色 F 可认为恒定(或已知函数)。
结论:若前景颜色本身随空间变化,仅凭一张白底结果图无法无损恢复原始 (F, α)。工程上要“尽量像”,必须引入先验与约束。

  1. 还原策略
    本文实现采用两阶段:结构优先 + 色调重映射

阶段 目标 说明
阶段 A:反推结构 还原 alpha 空间层次 从白底图直接估计每像素透明度,优先保持扩散范围与层次分布。
阶段 B:色调映射 替换到目标色系 在保留结构的基础上,把颜色映射到目标色;支持“强制对齐”增强中心色匹配。
“结构优先”:

若直接做 RGB 调色或 alpha 拉伸,容易出现“中心偏重、外圈变薄”的视觉偏差。 结构优先策略先锁定空间分布,再做色调替换,能更好贴近原图组织方式。

  1. 关键算法
    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 参数调节。

2

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;
}

在不同透明背景下,我们可以更换任意色调:

2

posted @ 2026-07-12 20:56  行人--  阅读(6)  评论(0)    收藏  举报