尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

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_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建后复制 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Cline 的 MCP 配置也是同理Base URL 填 https://taotoken.net/api Key 填你创建的那串Model ID 按需选。Codex 的 auth.json 里同样把这三项对齐。为什么要在光标采集项目里提这些因为一个完整的采集工具往往需要批量导出光标、自动命名、生成缩略图索引、甚至用模型判断光标用途。这些扩展功能如果各自维护凭证后期换 Key 会非常痛苦。统一通道的好处是改一处、全工程生效。你可以把 Key 放在环境变量TAOTOKEN_API_KEY里C# 用Environment.GetEnvironmentVariable读取避免硬编码进源码。对于长期做编码和 Agent 开发的场景可以考虑 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合需要持续调用、频繁调试的项目。如果只是想先验证模型能不能用直接去模型对话页面试一句https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。API Keys 管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 换 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.csusing 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 参数是不是0x00CC0020SRCCOPY。如果尺寸是 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面写了 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_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它比按次调用更适合高频场景。API Keys 轮换在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 操作建议给采集工具单独建一个 Key方便审计和禁用。最后给一个实用技巧采集到的 Bitmap 在保存前先调bmp.MakeTransparent()不一定有用因为 alpha 通道已经在了直接存 PNG 就能保留透明。如果你要叠加到截图上用Graphics.DrawImage时记得按热点偏移即destX mouseX - hotspotX否则光标尖角对不准鼠标位置。这个偏移量在录屏工具里差一个像素都会很明显。整套流程走下来Win32 层负责取形状TaoToken 通道负责后续的智能扩展两者解耦各自可替换。源码可以直接复制进你的工程改改命名空间就能用。
返回列表