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

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

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

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

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

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

验证码流程

 

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

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

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

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

 

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

 

二、图元绘制

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

 

2.1 初始化

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

 

using var bitmap = new SKBitmap(width, height);

using var canvas = new SKCanvas(bitmap);

canvas.Clear(SKColors.White); // 白色背景

2.2 图形绘制

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

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

 

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

}

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

duck_pure

 

2.2 字符绘制

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

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

 

 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对象后,进行随机背景的绘制,示例代码如下:

 

/// <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);

}

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

萤火初芒_texture

 

3.2 噪点和杂线

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

 

 // 画干扰线

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

 }

效果预览:

萤火初芒_texture_pt_line

 

四、干扰滤镜应用

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

 

4.1 文字随机旋转

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

 

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; // 前进到下一个字的位置

    }

}

预览如下:

萤火初芒_texture_pt_line_ang

 

4.2 波纹扭曲

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

 

            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

萤火初芒_all_20_3.5

 

waveLength=40,amplitude=7

萤火初芒_all_40_7

 

六、挖孔

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

 

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

    }

}

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值