从零开始: 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对象供后续操作了。
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;
canvas.DrawCircle(cx, cy, 35, stroke);
canvas.DrawCircle(cx + 25, cy - 20, 20, stroke);
canvas.DrawLine(cx + 45, cy - 20, cx + 60, cy - 18, stroke);
canvas.DrawLine(cx + 45, cy - 20, cx + 60, cy - 22, stroke);
using var dot = new SKPaint { Color = SKColors.Black, IsAntialias = true };
canvas.DrawCircle(cx + 30, cy - 25, 2.5f, dot);
canvas.DrawLine(cx - 35, cy - 5, cx - 45, cy + 5, stroke);
}
预览生成的鸭子图像如下:

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;
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对象后,进行随机背景的绘制,示例代码如下:
private static void CreateNoiseTexture(SKCanvas canvas)
{
var clip = canvas.LocalClipBounds;
int w = (int)clip.Width;
int h = (int)clip.Height;
using var bmp = new SKBitmap(w, h, SKColorType.Rgba8888, SKAlphaType.Opaque);
var rand = new Random();
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));
}
}
using var scanPaint = new SKPaint
{
Color = SKColors.White.WithAlpha(30),
Style = SKPaintStyle.Fill,
IsAntialias = true
};
for (int i = 0; i < 3; i++)
{
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);
}
using var texturePaint = new SKPaint { FilterQuality = SKFilterQuality.None };
using var texture = SKImage.FromBitmap(bmp);
canvas.DrawImage(texture, 0, 0, texturePaint);
}
以上代码放在图像初始化背景之后执行。以下是纹理叠加文字验证码的效果:

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);
}
}
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 文字随机旋转
独立绘制每个文字,并按随机角度生成:
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;
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);
canvas.Save();
canvas.Translate(x + fontWidth / 2, y);
canvas.RotateDegrees(rand.NextSingle() * 30 - 15);
canvas.Translate(-fontWidth / 2, 0);
canvas.DrawText(c.ToString(), 0, 0, font, textPaint);
canvas.Restore();
x += fontWidth;
}
}
预览如下:

4.2 波纹扭曲
正弦波扭曲整个图像,这里直接通过glsl创造一个shader实现,具体代码和详细注释如下:
public static SKBitmap WaveTortion(SKBitmap src,
SKPoint center,
float waveLength = 30,
float amplitude = 12)
{
using var texture = SKShader.CreateBitmap(
src,
SKShaderTileMode.Clamp,
SKShaderTileMode.Clamp);
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);
}";
using var effect = SKRuntimeEffect.CreateShader(glsl, out var err);
if (effect == null)
throw new Exception($"GLSL 编译失败:{err}");
var uniforms = new SKRuntimeEffectUniforms(effect)
{
["center"] = new[] { center.X, center.Y },
["waveLength"] = waveLength,
["amplitude"] = amplitude
};
var children = new SKRuntimeEffectChildren(effect)
{
["texture"] = texture
};
var info = new SKImageInfo(src.Width, src.Height);
using var surface = SKSurface.Create(info);
using var paint = new SKPaint();
paint.Shader = effect.ToShader(uniforms, children);
surface.Canvas.DrawRect(info.Rect, paint);
surface.Canvas.Flush();
return SKBitmap.FromImage(surface.Snapshot());
}
其中波长waveLength决定了图像的扭曲密集程度,取值越小,扭曲越密集;振幅amplitude决定了扭曲的剧烈程度,不同的组合取值效果示意如下:
waveLength=20,amplitude=3.5

waveLength=40,amplitude=7

六、挖孔
挖孔可应用与和用户行为结合的场景下,即拉动水平滚动条使局部图像与挖孔位置对其。主要思路是复制一个bitmap,通过设定BlendMode混合模式,实现单独绘制孔洞的形状和孔洞外的形状。为了示意我们把孔和洞分开了,真实场景下二者应该是同时出现的,具体代码及效果如下:
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);
using var circlePaint = new SKPaint { IsAntialias = true, Color = SKColors.White };
canvas.DrawCircle(snapshot.Width / 2, snapshot.Height / 2, radius, circlePaint);
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);
using var circlePaint = new SKPaint { IsAntialias = true, Color = SKColors.White };
canvas.DrawCircle(radius, radius, radius, circlePaint);
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;
}
}
整体与局部预览:

转自https://www.cnblogs.com/luojin765/p/19267103
该文章在 2026/2/5 9:44:06 编辑过