news 2026/10/3 11:57:50

C#实现抓取鼠标形状(附完整源码)——TaoToken 统一 Key 通道下的桌面光标采集实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现抓取鼠标形状(附完整源码)——TaoToken 统一 Key 通道下的桌面光标采集实践

1. 桌面光标采集到底难在哪:从 Cursor 句柄到 Bitmap 的完整链路

C# 抓取鼠标形状这件事,表面看只是「把当前光标存成图片」,真动手才会发现坑比想象中多。Windows 并没有提供一个GetCurrentCursorBitmap()这样的现成 API,系统只暴露了GetCursorInfo拿到一个 HCURSOR 句柄,而句柄背后可能是单色光标、彩色光标、动画光标(.ani),甚至是被某个应用临时替换的私有光标。你要做的是把这个句柄还原成一张带透明通道的位图,同时还要处理热点坐标和多 DPI 缩放。

先说清楚这套方案能做什么、适合谁。它适合三类人:一是做录屏/截图工具,需要在画面上叠加真实光标;二是做自动化测试或演示软件,要记录操作轨迹并还原光标外观;三是做光标素材采集工具,批量导出系统里各种光标形状。核心检索词就是「C# 抓取鼠标形状」,本质是通过 Win32 API 读取光标句柄,再用 GDI 把句柄转成 Bitmap。

我试过直接Cursor.Current.Draw()到 Graphics 上,结果透明区域全黑,热点也丢了。原因在于Cursor.Draw只适合把光标画到已有 DC,它不会帮你处理掩码和 alpha。正确路径是走GetIconInfo,拿到hbmColor和hbmMask两个位图,再根据是否单色决定合成方式。单色光标没有彩色位图,必须用掩码做 AND/XOR 运算还原;彩色光标则直接读hbmColor的 32 位像素,alpha 通道天然存在。

多 DPI 是第二个大坑。在 150% 缩放下,GetSystemMetrics(SM_CXCURSOR)返回的仍是 32,但实际光标可能是 48×48。如果你按固定 32 去读位图,边缘会被裁掉。解决办法是用GetIconInfo后配合GetObject查询BITMAP结构里的真实宽高,而不是相信系统度量值。另外热点坐标xHotspot/yHotspot也要按同样比例换算,否则叠加位置会偏。

还有一个容易被忽略的点:GetCursorInfo返回的句柄是共享资源,你不能DestroyCursor它,否则会影响系统。正确做法是只读取、不释放,或者用CopyIcon复制一份再操作。我在早期版本里直接对返回句柄调DestroyIcon,导致鼠标指针偶尔变成空白,排查了半天才定位到。

把这些环节串起来,整个流程是:GetCursorInfo取句柄 →GetIconInfo拆出掩码和彩色位图 → 判断单色/彩色 → 读像素合成 Bitmap → 记录热点 → 按 DPI 缩放。下面几节我会给出可直接复制的工程结构、完整源码、验证步骤,以及如何用 TaoToken 统一 Key 通道管理后续扩展时的调用凭证。

2. TaoToken 前置准备:统一 Key 通道与调用凭证管理

在写代码之前,先把凭证管理这件事理清楚。很多人在做光标采集工具时,后面会想加一个「AI 识别光标类型」或者「自动生成光标描述」的功能,这时候就需要调用大模型 API。如果每个功能都单独申请 Key、单独配置 Base URL,工程会变得很难维护。TaoToken 的思路是提供一个统一的 Key 通道,把模型调用、编码计划、控制台管理都收敛到一套凭证体系里。

你需要先拿到一个 API Key。访问控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建后复制 Key,注意它只在创建时完整显示一次。这个 Key 后面会用在两个地方:一是 C# 工程里的配置文件,二是如果你用 Claude Code 或 Cline 这类工具做辅助开发时的环境变量。

Base URL 统一用 https://taotoken.net/api ,不要加任何路径后缀。Model ID 根据你实际要调用的模型填,比如做代码补全可以用对应的编码模型。这里要强调三件套必须写全:Base URL、API Key、Model ID,缺一个都会报 401 或 model not found。

如果你打算用 Claude Code 做辅助开发,可以走 Anthropic 兼容通道,配置方式参考文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。Cline 的 MCP 配置也是同理,Base URL 填 https://taotoken.net/api ,Key 填你创建的那串,Model ID 按需选。Codex 的 auth.json 里同样把这三项对齐。

为什么要在光标采集项目里提这些?因为一个完整的采集工具往往需要:批量导出光标、自动命名、生成缩略图索引、甚至用模型判断光标用途。这些扩展功能如果各自维护凭证,后期换 Key 会非常痛苦。统一通道的好处是改一处、全工程生效。你可以把 Key 放在环境变量TAOTOKEN_API_KEY里,C# 用Environment.GetEnvironmentVariable读取,避免硬编码进源码。

对于长期做编码和 Agent 开发的场景,可以考虑 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它适合需要持续调用、频繁调试的项目。如果只是想先验证模型能不能用,直接去模型对话页面试一句:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。API Keys 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,换 Key、禁用旧 Key 都在这里操作。

把凭证准备好之后,回到 C# 工程本身。下一节给出完整的项目结构和可复制源码。

3. 可复制配置与完整源码:Win32 API 封装 + Cursor 转 Bitmap

先建一个 .NET 6 或 .NET Framework 4.7.2 的 WinForms 项目,命名为CursorCapture。需要引用System.Drawing.Common(.NET 6 用 NuGet 装)。项目结构建议分三个文件:Win32Interop.cs放 P/Invoke 声明,CursorCapture.cs放核心采集逻辑,Program.cs放入口和验证。

先看 Win32 结构体和 API 声明。这里的关键是ICONINFO和CURSORINFO两个结构,字段顺序不能错,否则读出来全是垃圾数据。

using System; using System.Drawing; using System.Runtime.InteropServices; namespace CursorCapture { [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT ptScreenPos; } [StructLayout(LayoutKind.Sequential)] public struct ICONINFO { public bool fIcon; public int xHotspot; public int yHotspot; public IntPtr hbmMask; public IntPtr hbmColor; } [StructLayout(LayoutKind.Sequential)] public struct BITMAP { public int bmType; public int bmWidth; public int bmHeight; public int bmWidthBytes; public ushort bmPlanes; public ushort bmBitsPixel; public IntPtr bmBits; } public static class Win32Interop { public const int CURSOR_SHOWING = 0x00000001; [DllImport("user32.dll")] public static extern bool GetCursorInfo(ref CURSORINFO pci); [DllImport("user32.dll")] public static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); [DllImport("user32.dll")] public static extern IntPtr CopyIcon(IntPtr hIcon); [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr hIcon); [DllImport("gdi32.dll")] public static extern bool GetObject(IntPtr hObject, int nCount, ref BITMAP lpObject); [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); } }

注意CURSORINFO.cbSize必须在调用前赋值,否则GetCursorInfo直接返回 false。这是最常见的第一个报错来源。

接下来是核心采集逻辑。思路是:先GetCursorInfo拿句柄,再CopyIcon复制一份避免影响系统,然后GetIconInfo拆出位图,用GetObject查真实尺寸,最后根据hbmColor是否为空判断单色还是彩色。

using System; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; namespace CursorCapture { public class CursorSnapshot { public Bitmap Bitmap { get; set; } public int HotspotX { get; set; } public int HotspotY { get; set; } public bool IsMonochrome { get; set; } } public static class CursorCapture { public static CursorSnapshot Capture() { var ci = new CURSORINFO(); ci.cbSize = Marshal.SizeOf(typeof(CURSORINFO)); if (!Win32Interop.GetCursorInfo(ref ci)) throw new InvalidOperationException("GetCursorInfo failed"); if (ci.flags != Win32Interop.CURSOR_SHOWING || ci.hCursor == IntPtr.Zero) return null; IntPtr hCopy = Win32Interop.CopyIcon(ci.hCursor); if (hCopy == IntPtr.Zero) throw new InvalidOperationException("CopyIcon failed"); try { ICONINFO ii; if (!Win32Interop.GetIconInfo(hCopy, out ii)) throw new InvalidOperationException("GetIconInfo failed"); try { bool mono = ii.hbmColor == IntPtr.Zero; IntPtr hbm = mono ? ii.hbmMask : ii.hbmColor; var bmp = new BITMAP(); Win32Interop.GetObject(hbm, Marshal.SizeOf(typeof(BITMAP)), ref bmp); int width = bmp.bmWidth; int height = mono ? bmp.bmHeight / 2 : bmp.bmHeight; Bitmap result = mono ? BuildMonochrome(ii.hbmMask, width, height) : BuildColor(ii.hbmColor, width, height); return new CursorSnapshot { Bitmap = result, HotspotX = ii.xHotspot, HotspotY = ii.yHotspot, IsMonochrome = mono }; } finally { if (ii.hbmMask != IntPtr.Zero) Win32Interop.DeleteObject(ii.hbmMask); if (ii.hbmColor != IntPtr.Zero) Win32Interop.DeleteObject(ii.hbmColor); } } finally { Win32Interop.DestroyIcon(hCopy); } } private static Bitmap BuildColor(IntPtr hbmColor, int width, int height) { var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); using (var g = Graphics.FromImage(bmp)) { IntPtr hdc = g.GetHdc(); try { IntPtr memDc = CreateCompatibleDC(hdc); IntPtr old = SelectObject(memDc, hbmColor); BitBlt(hdc, 0, 0, width, height, memDc, 0, 0, 0x00CC0020); SelectObject(memDc, old); DeleteDC(memDc); } finally { g.ReleaseHdc(hdc); } } return bmp; } private static Bitmap BuildMonochrome(IntPtr hbmMask, int width, int height) { var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); var data = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); try { int stride = data.Stride; byte[] buffer = new byte[stride * height]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int idx = y * stride + x * 4; buffer[idx] = 255; buffer[idx + 1] = 255; buffer[idx + 2] = 255; buffer[idx + 3] = 255; } } Marshal.Copy(buffer, 0, data.Scan0, buffer.Length); } finally { bmp.UnlockBits(data); } return bmp; } [DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr hdc); [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr hdc, IntPtr hObject); [DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr hdcDest, int xDest, int yDest, int w, int h, IntPtr hdcSrc, int xSrc, int ySrc, int rop); [DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr hdc); } }

单色光标的处理这里做了简化,实际生产环境需要读掩码位做 AND/XOR 还原,否则会丢失形状。如果你只是采集彩色光标(现代系统绝大多数是彩色),上面的BuildColor已经够用。要完整还原单色,需要额外读hbmMask的位数据,按「掩码为 1 处透明、为 0 处取 XOR 位」的规则合成,代码量会翻倍,建议先跑通彩色路径再扩展。

配置文件方面,如果你要接入 TaoToken 做后续扩展,建议在项目根目录放一个appsettings.json:

{ "TaoToken": { "BaseUrl": "https://taotoken.net/api", "ApiKey": "", "ModelId": "your-model-id" } }

ApiKey 不要提交到仓库,用环境变量覆盖。C# 读取时优先取Environment.GetEnvironmentVariable("TAOTOKEN_API_KEY"),为空再读配置文件。这样本地调试和 CI 都能兼顾。

4. 验证请求与成功结果:跑通采集并导出 PNG

源码写完后,用一段控制台入口验证。新建Program.cs:

using System; using System.Drawing.Imaging; using System.IO; using System.Windows.Forms; namespace CursorCapture { internal static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Console.WriteLine("3 秒后采集当前鼠标形状,请把光标移到窗口内..."); System.Threading.Thread.Sleep(3000); var snap = CursorCapture.Capture(); if (snap == null) { Console.WriteLine("当前没有可见光标"); return; } string outPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "cursor.png"); snap.Bitmap.Save(outPath, ImageFormat.Png); Console.WriteLine($"已保存: {outPath}"); Console.WriteLine($"尺寸: {snap.Bitmap.Width}x{snap.Bitmap.Height}"); Console.WriteLine($"热点: ({snap.HotspotX}, {snap.HotspotY})"); Console.WriteLine($"单色: {snap.IsMonochrome}"); } } }

运行后你会看到类似输出:

已保存: D:\CursorCapture\bin\Debug\net6.0-windows\cursor.png 尺寸: 32x32 热点: (1, 1) 单色: False

打开cursor.png,应该能看到一个带透明背景的箭头光标。如果背景是黑色而不是透明,说明 alpha 通道没读对,检查BuildColor里BitBlt的 rop 参数是不是0x00CC0020(SRCCOPY)。如果尺寸是 32×32 但图片被裁切,说明当前 DPI 下真实尺寸更大,需要用GetObject返回的bmWidth/bmHeight,代码里已经这么做了,确认没被硬编码覆盖。

验证多 DPI 时,把系统缩放调到 150%,重新运行。正常情况尺寸会变成 48×48 或 64×64,热点坐标也按比例放大。如果尺寸没变但图片模糊,说明你读的是缩放后的位图,需要在进程启动时声明 DPI 感知:

[STAThread] static void Main() { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); // ... }

SetHighDpiMode必须在任何窗口创建前调用,否则无效。这是 .NET Core/6 的写法,.NET Framework 需要用 manifest 声明dpiAware。

验证动画光标(.ani)时,GetCursorInfo返回的是当前帧的句柄,你只能采到某一帧。要采完整动画需要走LoadCursorFromFile加载 .ani 再逐帧解析,这超出本篇范围,但采集单帧已经能满足大部分录屏叠加需求。

如果你想验证 TaoToken 通道是否配好,可以在采集完成后加一段调用测试。用 HttpClient 发一个最小请求:

using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); var payload = new { model = modelId, messages = new[] { new { role = "user", content = "ping" } } }; var resp = await client.PostAsJsonAsync("https://taotoken.net/api/v1/chat/completions", payload); Console.WriteLine(await resp.Content.ReadAsStringAsync());

返回 200 且 body 里有 choices 字段,说明 Base URL、Key、Model ID 三件套都对。返回 401 就是 Key 问题,返回 model not found 就是 Model ID 写错。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

第一个高频报错是 401 Unauthorized。原因通常是 Key 没带对前缀,或者复制时多了空格。检查Authorization头是不是Bearer sk-xxx格式,中间一个空格。另外确认你用的是 https://taotoken.net/api 作为 Base URL,不要自己拼/v1之外的路径。如果 Key 是在控制台刚创建的,确认没有误删。换 Key 后记得重启进程,环境变量不会热更新。

第二个是local proxy failed。这个报错一般出现在你本地配了代理工具,但代理没启动或端口不对。C# 的 HttpClient 默认会读系统代理设置,如果你之前配过HTTP_PROXY环境变量,请求会先走代理再出去,代理挂了就报这个。解决办法是在代码里显式禁用代理:

var handler = new HttpClientHandler { UseProxy = false }; using var client = new HttpClient(handler);

或者检查系统环境变量里有没有残留的HTTP_PROXY/HTTPS_PROXY,清掉再试。注意这里说的是本地开发环境的网络配置问题,不是让你去搭什么通道,纯粹是排查环境变量。

第三个是reading choices相关报错,完整信息通常是Cannot read properties of undefined (reading 'choices')。这说明响应体里没有 choices 字段,多半是请求体格式不对。检查messages是不是数组、model字段名有没有拼错、Content-Type 是不是application/json。还有一种情况是 Base URL 少了/v1,请求打到了根路径返回了 HTML,解析 JSON 自然失败。正确路径是https://taotoken.net/api/v1/chat/completions。

第四个是 OAuth 相关报错。如果你用 Claude Code 或某些 CLI 工具,它们可能默认走 OAuth 登录流程而不是 API Key。这时候需要在配置里显式指定用 API Key 模式,把 Base URL 和 Key 填到对应字段。Claude Code 的配置参考 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面写了 Anthropic 兼容通道的填法。Cline 的 MCP 配置同理,三件套写全就不会触发 OAuth。

还有一个 C# 特有的坑:GetCursorInfo返回 false 但不抛异常。这通常是cbSize没赋值,或者结构体字段布局和系统不匹配(32 位/64 位混用)。确认你的项目平台目标和系统一致,IntPtr在 64 位下是 8 字节,结构体里别用int代替。

排查顺序建议:先确认光标采集本身能跑通(不涉及网络),再单独测 TaoToken 通道。两件事分开验证,出问题时能快速定位是 Win32 层还是网络层。

6. 从采集到扩展:把光标工具接进统一通道

光标采集跑通后,下一步通常是扩展成素材库工具。比如批量采集系统所有光标、自动生成预览图、按用途分类。分类这一步就可以接模型能力,把光标位图转成 base64 发给模型,让它判断是「箭头」「手型」「等待」「调整大小」还是「自定义」。这时候统一 Key 通道的价值就体现出来了:采集逻辑和模型调用共用一套凭证,换 Key 只改一个环境变量。

具体做法是在CursorSnapshot上加一个ToBase64()方法,把 Bitmap 存成 PNG 再转 base64。然后构造请求发给 https://taotoken.net/api ,Model ID 选一个支持视觉的模型。返回的分类结果写进文件名或 sidecar JSON,方便后续检索。

如果你要做的是长期运行的采集 Agent,比如定时抓取光标变化并记录,建议用 Coding Plan 管理调用配额:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它比按次调用更适合高频场景。API Keys 轮换在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 操作,建议给采集工具单独建一个 Key,方便审计和禁用。

最后给一个实用技巧:采集到的 Bitmap 在保存前先调bmp.MakeTransparent()不一定有用,因为 alpha 通道已经在了,直接存 PNG 就能保留透明。如果你要叠加到截图上,用Graphics.DrawImage时记得按热点偏移,即destX = mouseX - hotspotX,否则光标尖角对不准鼠标位置。这个偏移量在录屏工具里差一个像素都会很明显。

整套流程走下来,Win32 层负责取形状,TaoToken 通道负责后续的智能扩展,两者解耦,各自可替换。源码可以直接复制进你的工程,改改命名空间就能用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 11:55:40

Claude Code 使用指南:核心技能与最佳实践之代码调试与重构实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 11:55:03

Agent Skills 完全指南:从概念到集成 TaoToken 统一 Key 通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华