# 从零开始: 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对象供后续操作了。
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);
}
预览生成的鸭子图像如下:

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);
}
以上代码放在图像初始化背景之后执行。以下是纹理叠加文字验证码的效果:

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);
}
效果预览:

四、干扰滤镜应用
如果目前图像还是容易被识别,为了对抗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; // 前进到下一个字的位置
}
}
预览如下:

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

waveLength=40,amplitude=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;
}
}
整体与局部预览:

七、最后
以上分享了C#基于SkiaSharp实现验证码绘制的流程与效果展示,并列出了相关代码方便大家参考与修改了。长文不易,如果读到此处请给个关注吧!
如果你在阅读过程中有任何疑问,或者在实际操作中遇到了困难,欢迎随时与我们交流。我们非常期待听到你的反馈和建议,以便我们能够进一步完善内容,帮助更多开发者。请继续关注我们的公众号“萤火初芒”,我们将持续分享更多有趣且实用的技术内容,与大家一起学习交流,共同进步。

浙公网安备 33010602011771号