# 从零开始: C#图像验证码SkiaSharp跨平台—不只是“看得清”那么简单

从零开始: C#图像验证码跨平台轻松实现

在 Web 应用开发中,验证码(CAPTCHA)是一道常见的安全屏障。它不仅能有效防止暴力破解和机器人攻击,还能在登录、注册、评论等场景中保障系统的稳定性。而在 .NET 生态中,C# 作为主力语言,早已具备强大的图像处理能力。借助开源库和跨平台框架,我们完全可以在 Windows、Linux、macOS 上轻松生成高质量、可定制的图像验证码。

本文章将从零开始,单纯基于依赖SkiaSharp,实现具有图元绘制、噪声图形、滤镜干扰等功能 C# 图像验证码生成流程。

一、验证码原理:不只是"看得清"那么简单

验证码实现的完整流程大致如下:

验证码生成:当用户请求时,服务器端会生成并像向用户发送一条暗含信息的数据。

数据解构:用户收到数据后会对其进行解构并获取可能的真实信息。此时在规定时间内,真人可以轻松获取信息,而脚本或程序无法完成。

人机验证:用户将信息发送给服务端进行验证,进行人机验证(包括原始信息验证和行为验证)。

我们可以使用音频、视频、文本(出题)、图像等数据形式来承载隐藏的信息。其统一原则就是在真人可以快速识别出信息前提下,尽可能增加验证难度对抗代码程序化识别,以提高人机验证准确率。

以图像验证码为例,可以通过原始信息验证和行为验证两个方式提高人机验证的准确率。

对抗OCR或图形识别:提高机器程序对图像中字符文本或图形信息的提取难度;

行为验证:多样化交互模式(点击、滑动)、分析用户的鼠标轨迹、点击模式、滑动速度等行为特征结合原始信息比对综合验证。

下面我们主要从对抗OCR或图形识别的角度分享下图形验证码生成部分的实现,内容主要为以下四部分:图元绘制、干扰元素、形变滤镜、图形挖取。

二、图元绘制

项目基于SkiaSharp开发,只需要去Nuget拉取组件SkiaSharp,就可实现含文字图形渲染、编辑、编译GLSL(OpenGL着色器语言)创建shader等所需功能。本文使用的是3.119.1(老版本传参方式在新版本能用但已被标记为obsolete,本文中使用的方法均为3.x中的新版本方法)。

2.1 初始化

项目开始时,首先需要创建一个空白的bitmap和canvas用于图形绘制及存储。在创建后可以将canvas初始化成白色。这样,我们就有了一个基础的bitmap和canvas对象供后续操作了。

csharp 复制代码
using var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.White); // 白色背景

2.2 图形绘制

像Yandex网站的验证码是通过点击图形,而不是识别文字来实现人机验证的。因此我们需要验证码工具具备简单图形绘制的能力。

我们以画一个鸭子为示例, 其原则就是,传入之前初始化的canvas,再创建绘画板,在canvas上绘制图形:

csharp 复制代码
private static void DrawDuck(SKCanvas canvas)
{
    // 线条画笔
    using var stroke = new SKPaint
    {
        Color = SKColors.DarkRed,
        StrokeWidth = 3,
        IsAntialias = true,
        Style = SKPaintStyle.Stroke
    };

    float cx = canvas.LocalClipBounds.MidX,
          cy = canvas.LocalClipBounds.MidY;

    // 1. 身体(大圆弧当背+胸)
    canvas.DrawCircle(cx, cy, 35, stroke);          // 胖身体

    // 2. 脑袋(头顶小圆)
    canvas.DrawCircle(cx + 25, cy - 20, 20, stroke);

    // 3. 扁嘴(上下两条短直线)
    canvas.DrawLine(cx + 45, cy - 20, cx + 60, cy - 18, stroke);
    canvas.DrawLine(cx + 45, cy - 20, cx + 60, cy - 22, stroke);

    // 4. 小圆眼
    using var dot = new SKPaint { Color = SKColors.Black, IsAntialias = true };
    canvas.DrawCircle(cx + 30, cy - 25, 2.5f, dot);

    // 5. 尾巴(一小撇)
    canvas.DrawLine(cx - 35, cy - 5, cx - 45, cy + 5, stroke);
}

预览生成的鸭子图像如下:

2.2 字符绘制

字符绘制是验证码的常见形式,可以是数字符号,也可以是中文。

我们同样传入之前的canvas对象,再创建绘画板,并设定要绘制文字的字体以及位置。MeasureText可以估计文本的宽度,font.Metrics可以估计文本基线到顶部距离,这两个属性可以帮助我们定位文字。

csharp 复制代码
 private static void DrawText(SKCanvas canvas, string text)
 {
     using var textPaint = new SKPaint
     {
         Color = SKColors.DarkRed,
         IsAntialias = true
     };

     var tf = SKFontManager.Default.MatchFamily("Microsoft YaHei", SKFontStyle.Normal);
     using var font = new SKFont(tf, canvas.LocalClipBounds.Height * 0.4f);

     var rand = new Random();
     var clip = canvas.LocalClipBounds;

     // 1. 先算总宽(未旋转状态)
     float totalWidth = font.MeasureText(text, textPaint);
     float x = clip.MidX - totalWidth / 2;          // 整体水平居中起点
     float y = clip.MidY + font.Metrics.CapHeight / 2;  // 计算文字从基线到顶部的距离

     canvas.DrawText(text, x, y, font, textPaint);
     return;
 }

预览生成的文本图像如下:

现在我们已经能绘制核心元素了。可以通过系统随机选择图形或随机生成字符作为验证码的原始信息。

但由于生成的图像过于简单了,也很容易被OCR等程序直接读取并捕获,因此我们需要进一步对验证码进行处理。后续案例均以文本验证码为例。

三、干扰元素绘制

在这里我们主要实现三类干扰元素,干扰纹理、噪点、杂线(直线和曲线)。

3.1 干扰纹理

干扰纹理主要目的是对背景进行干扰,通过生成随机的纹理来对抗OCR。同样的,我们传入canvas对象后,进行随机背景的绘制,示例代码如下:

csharp 复制代码
/// <summary>
/// 在传入画布上铺满一层"电视雪花"噪点纹理,并叠 3 条斜向扫描光斑,最后以原尺寸绘制。
/// </summary>
/// <param name="canvas">目标画布,纹理将铺满其 LocalClipBounds 区域。</param>
private static void CreateNoiseTexture(SKCanvas canvas)
{
    // 1. 取得画布当前可见区域(整数尺寸)
    var clip = canvas.LocalClipBounds;
    int w = (int)clip.Width;
    int h = (int)clip.Height;

    // 2. 创建临时位图,用于生成噪点
    using var bmp = new SKBitmap(w, h, SKColorType.Rgba8888, SKAlphaType.Opaque);
    var rand = new Random();

    /* 3. 逐像素写入随机灰度,形成"雪花"噪点
          值域 230-255 保证噪点偏亮,不会把背景压得太暗 */
    for (int y = 0; y < h; y++)
    {
        for (int x = 0; x < w; x++)
        {
            byte v = (byte)rand.Next(230, 255); //控制背景纹理整体明暗度
            bmp.SetPixel(x, y, new SKColor(v, v, v));
        }
    }

    // 4. 生成 3 条斜向"扫描光斑",模拟老式 CRT 的反光条纹
    using var scanPaint = new SKPaint
    {
        Color = SKColors.White.WithAlpha(30), // 半透明白光
        Style = SKPaintStyle.Fill,
        IsAntialias = true
    };

    for (int i = 0; i < 3; i++)
    {
        // 每条光斑由 4 个顶点构成平行四边形,宽度约 20 像素
        var path = new SKPath();
        float y0 = i * h / 3f;
        path.MoveTo(0, y0);
        path.LineTo(w, y0 + 80);
        path.LineTo(w, y0 + 100);
        path.LineTo(0, y0 + 20);
        path.Close();
        canvas.DrawPath(path, scanPaint);
    }

    // 5. 把刚才做好的噪点图一次性画到目标画布,保持 1:1 像素对齐
    using var texturePaint = new SKPaint { FilterQuality = SKFilterQuality.None }; // 禁用插值,保持锐利
    using var texture = SKImage.FromBitmap(bmp);
    canvas.DrawImage(texture, 0, 0, texturePaint);
}

以上代码放在图像初始化背景之后执行。以下是纹理叠加文字验证码的效果:

3.2 噪点和杂线

同样的套路,直接生成随机点和线即可

csharp 复制代码
 // 画干扰线
 using (var linePaint = new SKPaint
 {
     Color = new SKColor(0, 0, 0, 90),
     StrokeWidth = 1,
     IsAntialias = true
 })
 {
     for (int i = 0; i < 6; i++)
     {
         var p1 = new SKPoint(rnd.Next(width), rnd.Next(height));
         var p2 = new SKPoint(rnd.Next(width), rnd.Next(height));
         canvas.DrawLine(p1, p2, linePaint);
     }
     // 或者用SKPath生成贝塞尔干扰线...
 }


 // 画噪点
 using (var pointPaint = new SKPaint { Color = new SKColor(0, 0, 0, 120) })
 {
     for (int i = 0; i < width * height / 150; i++)
         canvas.DrawPoint(rnd.Next(width), rnd.Next(height), pointPaint);
 }

效果预览:

四、干扰滤镜应用

如果目前图像还是容易被识别,为了对抗OCR,我们要开始对原始图像进行形变了。这里尝试的方法主要有文字旋转+整体波纹扭曲。

4.1 文字随机旋转

独立绘制每个文字,并按随机角度生成:

csharp 复制代码
private static void DrawText(SKCanvas canvas, string text)
{
    using var textPaint = new SKPaint
    {
        Color = SKColors.DarkRed,
        IsAntialias = true
    };

    var tf = SKFontManager.Default.MatchFamily("Microsoft YaHei", SKFontStyle.Normal);
    using var font = new SKFont(tf, canvas.LocalClipBounds.Height * 0.4f);

    var rand = new Random();
    var clip = canvas.LocalClipBounds;

    // 1. 先算总宽(未旋转状态)
    float totalWidth = font.MeasureText(text, textPaint);
    float x = clip.MidX - totalWidth / 2;          // 整体水平居中起点
    float y = clip.MidY + font.Metrics.CapHeight / 2;
    
    // 独立绘制每个字符
    foreach (var c in text)
    {
        float fontWidth = font.MeasureText(c.ToString(), textPaint);

        // 2. 每次保存当前画布状态
        canvas.Save();

        // 3. 以字符基线中心为原点旋转
        canvas.Translate(x + fontWidth / 2, y);
        canvas.RotateDegrees(rand.NextSingle() * 30 - 15); // ±15°
        canvas.Translate(-fontWidth / 2, 0);

        // 4. 画单个字符
        canvas.DrawText(c.ToString(), 0, 0, font, textPaint);

        // 5. 恢复画布,不影响下一个字
        canvas.Restore();

        x += fontWidth; // 前进到下一个字的位置
    }
}

预览如下:

4.2 波纹扭曲

正弦波扭曲整个图像,这里直接通过glsl创造一个shader实现,具体代码和详细注释如下:

csharp 复制代码
            public static SKBitmap WaveTortion(SKBitmap src,
                                           SKPoint center,
                                           float waveLength = 30,
                                           float amplitude = 12)
            {
                /***************************************************************
                 * 第 1 步:把 CPU 里的 SKBitmap 包装成 GPU 可用的纹理采样器
                 * 参数 2、3 是"越界采样模式":
                 *   Clamp 表示"边缘拉伸",避免边缘出现重复采样
                 ***************************************************************/
                using var texture = SKShader.CreateBitmap(
                                        src,
                                        SKShaderTileMode.Clamp,
                                        SKShaderTileMode.Clamp);

        /***************************************************************
         * 第 2 步:写一段 GLSL 片段着色器,告诉 GPU 每个像素怎么算
         * 语法是 Skia 的 RuntimeEffect 方言,和 OpenGL ES 2.0 几乎一样
         * 逐行解释写在注释里(注意:字符串里不能用 //)
         ***************************************************************/
                const string glsl = @"
/* 0. Skia 规定:入口函数必须是 half4 main(vec2 coord)
   coord = 当前像素的"画布坐标"(像素单位) */
uniform shader texture;   /* 1. 声明一张纹理采样器,名字随意 */
uniform vec2   center;    /* 2. 波纹中心,由 C# 传进来 */
uniform float  waveLength;/* 3. 波长 λ */
uniform float  amplitude; /* 4. 振幅 A */

half4 main(vec2 coord)
{
    /* 5. 计算当前像素到中心的向量 */
    vec2  dt = coord - center;

    /* 6. 求径向距离 r = √(dx²+dy²) */
    float r  = length(dt);

    /* 7. 波纹偏移量:正弦函数
         sin(r / λ * 2π) 保证一个完整周期长度正好是 λ 像素
         再乘以振幅 A,单位变成"像素" */
    float offset = sin(r / waveLength * 6.2831853) * amplitude;

    /* 8. 求单位方向向量,避免 r==0 时除 0 */
    vec2  dir = (r > 0.0) ? dt / r   /* 单位化 */
                          : vec2(0); /* 中心点直接给 0 */

    /* 9. 把当前像素坐标沿着径向推拉,得到"采样坐标" */
    vec2  uv = coord + dir * offset;

    /* 10. 用新坐标去纹理里采样,返回颜色 */
    return texture.eval(uv);
}";

                /***************************************************************
                 * 第 3 步:编译 GLSL
                 * 如果写错语法,Skia 会把错误字符串塞到 out 参数 err
                 ***************************************************************/
                using var effect = SKRuntimeEffect.CreateShader(glsl, out var err);
                if (effect == null)
                    throw new Exception($"GLSL 编译失败:{err}");

                /***************************************************************
                 * 第 4 步:把 C# 变量映射到 GLSL 的 uniform
                 * 注意:数组类型必须和 GLSL 声明完全一致
                 * vec2 → float[2] ,float → float
                 ***************************************************************/
                var uniforms = new SKRuntimeEffectUniforms(effect)
                {
                    ["center"] = new[] { center.X, center.Y },
                    ["waveLength"] = waveLength,
                    ["amplitude"] = amplitude
                };

                /* 还要把"纹理采样器"绑定到 uniform shader */
                var children = new SKRuntimeEffectChildren(effect)
                {
                    ["texture"] = texture
                };

                /***************************************************************
                 * 第 5 步:创建一块空画布,大小和原图一致
                 * 然后整张贴一个矩形,用刚才的 Shader 来"刷"颜色
                 ***************************************************************/
                var info = new SKImageInfo(src.Width, src.Height);
                using var surface = SKSurface.Create(info);   // 离屏 GPU 画布
                using var paint = new SKPaint();            // 画笔
                paint.Shader = effect.ToShader(uniforms, children); // 关键:把特效当笔刷
                surface.Canvas.DrawRect(info.Rect, paint);    // 画满整张画布
                surface.Canvas.Flush();                       // 确保命令立即提交

                /***************************************************************
                 * 第 6 步:把 GPU 里的结果读回 CPU,生成新位图
                 * FromImage 会拷贝一份,调用方可安全 Dispose 原 surface
                 ***************************************************************/
                return SKBitmap.FromImage(surface.Snapshot());
            }

其中波长waveLength决定了图像的扭曲密集程度,取值越小,扭曲越密集;振幅amplitude决定了扭曲的剧烈程度,不同的组合取值效果示意如下:

waveLength=20,amplitude=3.5

waveLength=40,amplitude=7

六、挖孔

挖孔可应用与和用户行为结合的场景下,即拉动水平滚动条使局部图像与挖孔位置对其。主要思路是复制一个bitmap,通过设定BlendMode混合模式,实现单独绘制孔洞的形状和孔洞外的形状。为了示意我们把孔和洞分开了,真实场景下二者应该是同时出现的,具体代码及效果如下:

csharp 复制代码
/// <summary>
/// 从源画布中心截取一个半径为 radius 的圆,返回一张新的 SKBitmap。
/// </summary>
/// <param name="sourceCanvas">源画布,仅用于获取尺寸和截取时的中心参考。</param>
/// <param name="radius">圆半径(像素)。</param>
/// <returns>仅包含圆形区域的透明背景位图。</returns>
private static SKBitmap CutCircle(SKBitmap snapshot, int radius = 10, bool keepCircle = true)
{

    if (keepCircle == false)
    {
        var circleBmp = new SKBitmap(snapshot.Width, snapshot.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
        using var canvas = new SKCanvas(circleBmp);
        // 1. 先画一个实心圆(作为 Source)
        using var circlePaint = new SKPaint { IsAntialias = true, Color = SKColors.White };
        canvas.DrawCircle(snapshot.Width / 2, snapshot.Height / 2, radius, circlePaint);

        // 2. 再用 SrcIn 把原图叠上去,只保留与圆重叠的部分
        using var imgPaint = new SKPaint { BlendMode = SKBlendMode.SrcOut };
        var srcRect = new SKRect(0, 0, snapshot.Width, snapshot.Height);
      
        canvas.DrawBitmap(snapshot, srcRect,  imgPaint);
        return circleBmp;

    }
    else
    {
        var circleBmp = new SKBitmap(radius * 2, radius * 2, SKColorType.Rgba8888, SKAlphaType.Premul);
        using var canvas = new SKCanvas(circleBmp);

        // 1. 先画一个实心圆(作为 Source)
        using var circlePaint = new SKPaint { IsAntialias = true, Color = SKColors.White };
        canvas.DrawCircle(radius, radius, radius, circlePaint);

        // 2. 再用 SrcIn 把原图叠上去,只保留与圆重叠的部分
        using var imgPaint = new SKPaint { BlendMode = SKBlendMode.SrcIn };
        var srcRect = new SKRect(0, 0, snapshot.Width, snapshot.Height);
        var dstRect = new SKRect(-(snapshot.Width / 2 - radius),
                                 -(snapshot.Height / 2 - radius),
                                 -(snapshot.Width / 2 - radius) + snapshot.Width,
                                 -(snapshot.Height / 2 - radius) + snapshot.Height);
        canvas.DrawBitmap(snapshot, srcRect, dstRect, imgPaint);

        return circleBmp;
    }
}

整体与局部预览:

七、最后

以上分享了C#基于SkiaSharp实现验证码绘制的流程与效果展示,并列出了相关代码方便大家参考与修改了。长文不易,如果读到此处请给个关注吧!

如果你在阅读过程中有任何疑问,或者在实际操作中遇到了困难,欢迎随时与我们交流。我们非常期待听到你的反馈和建议,以便我们能够进一步完善内容,帮助更多开发者。请继续关注我们的公众号"萤火初芒",我们将持续分享更多有趣且实用的技术内容,与大家一起学习交流,共同进步。