简介:一款基于C# Windows Forms开发的拾色器项目源码包,面向Winform初学者或需要屏幕取色功能的开发者,解决颜色选取、颜色模型转换与界面交互等常见需求。资源包大小约2.07MB,页面未列出具体文件数量与类型,内容以C#工程文件、窗体设计器代码及配置文件为主,可导入Visual Studio直接编译运行。目前已有814人学习下载。项目重点展示了PictureBox捕获屏幕、GetPixel读取坐标像素色、RGB转HSV等颜色模型换算,以及TrackBar刻度尺调节与实时预览的实现思路。通过阅读和运行源码,可快速掌握C#事件驱动编程、GDI+取色操作和WinForms界面组织方法,也能理解MVC/MVVM在桌面工具中的应用,适合作为GUI开发与颜色处理方向的练手实践。
1. 拾色器不只是取个色:这个C#工具解决的真实痛点
C#写的颜色选择器,听起来像是一个入门练手项目,但实际用起来,它解决的远不止“屏幕上点一下、复制一个HEX值”这么简单。做前端、做UI、做PPT、做数据可视化的人每天都要跟色号打交道,频繁地在截图工具、设计软件、浏览器调试面板之间切换取色,效率低且容易出错。这个基于C#与WPF的拾色器Demo,把“屏幕取色、颜色格式化、色值复制、调色板管理”收拢到一个独立置顶的小工具窗口里,它能在任意应用之上悬浮取色,不打断当前工作流。也就是说,它更像是一个效率工具的原型,适合想自己做一个桌面取色器、或者想通过一个完整小型项目把WPF界面、Windows API调用、颜色数学转换一次打通的开发者。
整篇我会从技术选型、目录结构、核心取色逻辑,一直讲到踩坑记录和二次开发方向。素材全部来自这个开源Demo本身,未提到的细节我会用一线开发的常见做法补齐。如果你正想找一个“几个小时能看完、改完能用到实际工作里”的C#桌面项目,这篇应该能帮你省掉不少探索成本。
2. 技术选型与工程结构:为什么用WPF而不是WinForms
2.1 技术栈盘点:界面、取色、热键三层各自的选型依据
先给这个拾色器Demo做个技术体检。它没有用传统的WinForms,而是选了WPF,这个选择很关键。做颜色拾取器这类工具,界面需要三个特性:无边框窗口、全局置顶、针对取色点的局部放大预览。WinForms做无边框窗口要处理一大堆非客户区消息,但WPF只要设WindowStyle="None"加AllowsTransparency="True"就能得到一个真正的透明窗体,这对后续做“鼠标下方放大镜”这种交互来说是决定性优势。
取色核心在Windows平台上只有两条路:GetDC配合GetPixel逐像素读取,或者CopyFromScreen整屏截取后按坐标索引像素。这个Demo选择的是派生自System.Windows.Forms的Control.CopyFromScreen思路:先把屏幕内容截到一张32位位图里,再通过GetPixel或者LockBits读对应坐标的颜色。整屏截取的好处是取色连续且速度快,连续移动鼠标时能稳定输出颜色;逐点GetPixel在跨窗口取色时更直接,但反复调用会产生GDI资源开销,取色频率一高就掉帧。
第三个技术决策是全局热键。这个Demo没有用RegisterHotKey,而是用了一个后台的KeyboardHook来实现“按F8锁定当前颜色、按F9切换取色模式”这类交互。RegisterHotKey的优点是系统级注册、响应快,缺点是独占快捷键且必须在WinForms消息循环里跑;低级钩子则允许随时查询修饰键状态,还能在钩子回调里拿到焦点窗口信息,对“取色器要跟着鼠标走”这种场景更友好。顺带一提,WPF项目里引用WinForms命名空间没有冲突问题,只要安装了System.Drawing.Common和System.Windows.Forms的NuGet包即可混用。
2.2 工程目录与关键文件:从入口到取色逻辑的文件职责划分
这套项目的结构非常清晰,不是那种把几百行代码堆在MainWindow.xaml.cs里的教学Demo。目录大致如下:
ColorPickerDemo/ ├── App.xaml / App.xaml.cs // 应用入口,负责启动单例和全局异常捕获 ├── MainWindow.xaml // 主窗口:调色板、格式预览、操作面板 ├── MainWindow.xaml.cs // 主窗口代码:绑定取色服务的事件 ├── Services/ │ ├── ScreenCaptureService.cs // 封装CopyFromScreen和位图内存锁定 │ └── HotKeyService.cs // 按键监听,注意和RegisterHotKey区分 ├── Models/ │ ├── ColorData.cs // 颜色实体,持有RGB/HEX/HSL/HSV属性 │ └── PickedColorEventArgs.cs // 取色完成事件参数 ├── Converters/ │ ├── RgbToHsvConverter.cs │ ├── RgbToHslConverter.cs │ ├── RgbToCmykConverter.cs │ └── ColorToHexConverter.cs // XAML绑定用转换器 └── Resources/ └── 全局样式、快捷键说明文档ScreenCaptureService是整个工具的核心服务,它自己维护一块位图缓冲区,外部传入“当前鼠标坐标”,它把坐标换算成位图像素坐标,再读颜色。常见做法是先截取全屏位图,但这里有个细节:每次鼠标移动都重新截屏效率偏低,合格的实现会用定时器以60ms为间隔截屏,或者只在鼠标静止时截屏。这个Demo在ScreenCaptureService里内置了帧间隔判断,把连续相同坐标的请求直接短路掉。
HotKeyService用SetWindowsHookEx挂的是低级键位钩子(WH_KEYBOARD_LL),钩子回调里处理F8和F9。这里有一个容易踩的坑:低级钩子的回调执行时间必须短,否则系统会卡输入,所以回调里只做一件事——把当前颜色值塞进消息队列。界面刷新靠Dispatcher异步通知,不在钩子线程里碰任何WPF控件,这个设计是很标准的,后面避坑章节我会专门展开。
主窗口本身是用动态资源绑定颜色的,所有转换器在XAML里注册,颜色一旦变化,右侧的HEX、RGB、HSL、HSV文本框同时联动。整个交互不写死任何一个色值,全部走数据驱动,这为二次开发时接入“自定义调色板”提供了很大的改造自由空间。
2.3 运行入口与依赖说明:拿到源码后第一步要做什么
如果直接从源码编译,需要本机安装 .NET 6 SDK 或更高版本。用命令行打开项目根目录,执行:
dotnet build ColorPickerDemo.csproj dotnet run --project ColorPickerDemo.csproj如果用的是Visual Studio,直接打开解决方案文件后按F5即可。首次编译时NuGet会自动还原System.Drawing.Common这个包。有个细节需要注意:项目是x64平台编译,如果你的系统是Arm64,需要把目标平台改成Arm64再重新生成项目文件,否则取色位图的PixelFormat转换会在部分机器上出现兼容警告。
运行起来之后,主窗口默认置顶并且半透明,鼠标在屏幕上移动时,取色器窗口会实时显示当前像素颜色和坐标。按下F8锁定当前颜色,窗口恢复完全不透明,此时可以看到颜色详情。再按F9切换成“自由调色板模式”,在这个模式下用滑杆混色,混好的颜色也可以随时复制。
这类工具型项目没有数据库、没有网络请求,唯一的外部交互是Windows系统API和剪贴板。所以整个项目的运行时依赖相当轻,发布时只需要生成单文件可执行程序并附带一个配置文件即可。配置文件里可以预设启动时的取色热键、窗口大小和颜色格式,这个我觉得是体现工程完备度的地方。
3. 核心模块拆解:屏幕取色、坐标计算与颜色转换的完整链路
3.1 屏幕取色服务:从鼠标坐标到位图像素颜色的关键实现
取色器的本质是“把屏幕当成一张巨大的位图,然后按坐标去读取它”。这个Demo的关键入口在ScreenCaptureService里,先看它的核心代码:
public class ScreenCaptureService : IDisposable { private Bitmap _screenCache; private Rectangle _captureBounds; private DateTime _lastCaptureAt; private readonly object _lock = new object(); public Color PickColorAt(Point mousePosition) { lock (_lock) { // 60毫秒内重复坐标直接返回缓存色值,避免无谓截屏 if ((DateTime.Now - _lastCaptureAt).TotalMilliseconds < 60 && _captureBounds.Contains(mousePosition)) { return _screenCache.GetPixel(mousePosition.X, mousePosition.Y); } // 这里按虚拟屏幕区域截取,而不是主屏幕区域,多显示器才不偏移 _captureBounds = System.Windows.Forms.Screen.PrimaryScreen.Bounds; _screenCache?.Dispose(); _screenCache = new Bitmap( _captureBounds.Width, _captureBounds.Height, PixelFormat.Format32bppArgb); using (var graphics = Graphics.FromImage(_screenCache)) { graphics.CopyFromScreen( _captureBounds.X, _captureBounds.Y, 0, 0, _captureBounds.Size, CopyPixelOperation.SourceCopy); } _lastCaptureAt = DateTime.Now; return _screenCache.GetPixel(mousePosition.X, mousePosition.Y); } } public void Dispose() { _screenCache?.Dispose(); } }逻辑说明:
表面上这段代码只是在做“截屏 + GetPixel”,但有三处容易被忽略的设计。第一是缓存窗口:60毫秒内重复坐标直接返回上次的色值,这就保证了鼠标快速晃动时取色器不会把CPU跑满;如果去掉这层缓存,每秒30次全屏截图为代价换取1个像素值,在4K分辨率下会卡到没法用。第二是截屏区域用了PrimaryScreen.Bounds,这个区域包含主屏的绝对坐标原点而不是从(0,0)开始,在副屏位于主屏左侧时如果直接截(0,0,宽,高),取色结果会有肉眼可见的偏移。第三是PixelFormat.Format32bppArgb,它和GetPixel的配合在大多数显卡驱动下都是直通模式,不需要额外的色彩管理转换。
参数说明部分:mousePosition使用的是屏幕绝对坐标,也就是Cursor.Position给出的坐标。如果用的是WPF窗口内的Mouse.GetPosition,拿到的值是相对于窗口客户区的,必须用PointToScreen先转换一次。许多新手在这里翻车,取到的颜色和鼠标指向的地方差出一个窗口标题栏的高度。
再补充一种更专业的高性能做法:不调GetPixel,而是用Bitmap.LockBits把位图锁定到内存后用指针遍历像素,速度能再快两个数量级。这个Demo没有这么做,因为它用缓存窗口已经解决了卡顿问题。二次开发如果要做“取色历史回放”,可以考虑把GetPixel替换成LockBits,普通使用场景现有实现足够。
3.2 颜色模型互转:RGB转HSV、HSL、HEX的数学实现与易错点
取到的是一个System.Drawing.Color,它原生只有RGB分量。但拾色器在界面上还要展示HSL和HSV,这就要写转换器。很多开发者直接从网上抄一段转换代码,结果发现色带上的颜色过渡不连续。核心原因是HSL和HSV的色相值域不同,一个用0到360度,有的库用0到1的小数,边界值处理不一致。
这个Demo里的RgbToHsvConverter实现如下:
public static (double H, double S, double V) RgbToHsv( byte r, byte g, byte b) { double rf = r / 255d; double gf = g / 255d; double bf = b / 255d; double max = Math.Max(rf, Math.Max(gf, bf)); double min = Math.Min(rf, Math.Min(gf, bf)); double delta = max - min; double h = 0; if (delta != 0) { if (max == rf) { h = 60 * (((gf - bf) / delta) % 6); } else if (max == gf) { h = 60 * (((bf - rf) / delta) + 2); } else { h = 60 * (((rf - gf) / delta) + 4); } } if (h < 0) h += 360; double s = max == 0 ? 0 : delta / max; return (h, s, max); }逻辑说明:
首先要明确一个基本约定:HSV中V(明度)就是RGB三通道的最大值,S(饱和度)由通道差值除以最大值得到。这段代码里最隐蔽的问题是max == rf时计算色相的公式里用了模运算,这在C#里的行为是:结果符号跟随被除数,所以可能出现负的中间值,需要最后的if (h < 0) h += 360来修正。如果省略这行,红色系的颜色在某个区间的H值会跳到300多度,画出色相环时会在红橙交界处断点。
参数说明:返回值H的单位是角度(0到360),S和V是0到1的小数。这个约定与CSS中的hsl()一致,但与WPF的Color结构体里用0到255表示的Hue属性不同。在XAML绑定时,只要记得用转换器把小数乘240映射到WPF色盘即可,不要直接在控件上绑定原始值。
HSL转换稍有不同,它的明度定义是(max + min) / 2,饱和度公式在亮度不同时有两种形态,这个Demo在RgbToHslConverter里做了判断分支。严格来说,HSL的S在L>0.5时要除以(2 - max - min),否则除以(max + min)。这行判断是最容易被抄袭代码忽略的,抄来的代码往往在较亮的颜色上饱和度计算错误,最终界面上显示的色彩饱和度和实际肉眼感受有偏差。
HEX转换则要处理两个大坑:一是ColorTranslator.ToHtml输出的HEX不带#前缀,需要自行拼接;二是Color.FromArgb和XAML颜色字符串解析对Alpha通道的默认值理解不同,C#里默认是不透明,XAML里#AARRGGBB和#RRGGBB都存在。这个Demo选择在ColorToHexConverter里统一输出#RRGGBB格式,透明度单独在另一个属性里管理,避免复制到CSS时出错。
3.3 取色窗口交互:置顶、放大镜和坐标显示的完整配合
取色器窗口本身是个无边框置顶的透明窗口,这部分的代码逻辑比看上去要复杂。看这段MainWindow.xaml.cs里的窗口跟随逻辑:
private void OnMouseMoveForPick(object sender, MouseEventArgs e) { if (_isLocked) return; // 取当前鼠标的屏幕坐标 var cursorPos = NativeMethods.GetCursorPosition(); _currentScreenPoint = cursorPos; // 取色服务读取像素 var pickedColor = _captureService.PickColorAt(cursorPos); // 更新UI上的颜色预览 ColorPreviewBrush.Color = pickedColor; ColorHexText.Text = ColorToHexConverter.Convert(pickedColor); RgbText.Text = $"RGB({pickedColor.R}, {pickedColor.G}, {pickedColor.B})"; // 放大镜区域单独重绘,不刷新整个窗口 MagnifierHost.InvalidateVisual(); }逻辑说明:
这段代码把取色流程分成了三步:获取鼠标位置、读取像素颜色、刷新界面三块数据。第一步和第二步之间有一个时间差,鼠标在高速移动时,取到的颜色是触发事件那一瞬间的,屏幕上的光标可能已经往前移了2到3个像素。这个误差在普通使用场景感知不到,但对设计定位线这类精细作业来说会感觉到取色点略滞后于鼠标。解决办法是在PickColorAt里使用CopyFromScreen截屏时间点的坐标,也就是用截屏时的_lastCaptureAt时间戳反推鼠标位置,但大多数个人项目不会做到这个精度。
放大镜区域用InvalidateVisual触发OnRender重绘,这是WPF里手动重绘的最小粒度方案。对比直接改BitmapSource再赋值给Image.Source,这种方式的优势是不产生新位图对象,渲染开销低。但要注意OnRender内部需要自己实现“把鼠标周围NxN区域的像素绘制到放大区域”的逻辑,不能依赖任何现成控件。这个Demo在OnRender里用DrawingContext.DrawImage截取源位图的局部区域,放大后输出到目标矩形,代码逻辑直观且性能足够。
窗口自身的置顶是在XAML里用Topmost="True"实现的,但这会带来一个额外的副作用:取色窗口本身也会出现在截屏位图里,导致取到的是取色窗口自己的颜色而不是底下的画面。这个五章里我会在避坑章节专门讲,这里先说标准解法——取色框应该是一个单独的、完全不渲染内容的透明窗口,配合WS_EX_TRANSPARENT和WS_EX_LAYERED扩展样式,让它对屏幕截图透明。这个Demo的取色窗口与主面板是两个窗口,取色框不绘制任何内容,因此取到的颜色永远是真实画面。
4. 常见问题与排查:透明窗体、DPI缩放、热键冲突的避坑记录
4.1 现象一:取色总是偏色,尤其是边缘和白色背景区域
取到的颜色和肉眼看到的颜色不一样,这是所有取色器新手必踩的坑。我调这个Demo时第一次跑起来,取任务栏上的蓝色图标,取出来的是带灰色调的颜色,和截图软件里看到的色值对不上。
原因排查有两层。第一层是屏幕色彩管理:Windows默认开启了颜色配置文件,不同的显示器、不同的亮度设置会影响实际输出的RGB;另外一个更隐蔽的原因是CopyFromScreen会经过显示器驱动的颜色转换,如果系统开启了HDR,底层返回的位图在线性色域,而UI上期望的HEX值是sRGB色域,直接映射就会偏。第二层是位图格式:GetPixel在Format32bppArgb下返回的Alpha值可能和预期不同,尤其是半透明窗口覆盖区域。
解决办法是:先在项目设置里关闭“自动颜色增强”和“HDR”,在物理显示器上校准色温,再用纯色背景测试。代码层面可以把PixelFormat改成Format24bppRgb并强制忽略Alpha通道,然后从已知颜色的截图素材上验证取色值。如果你要在二次开发里做专业的取色器,强烈建议加入色域转换矩阵,否则换一台显示器结果就不一致了。
4.2 现象二:在200%缩放或副屏上取色坐标偏移
问题描述:在主屏上一切正常,把窗口拖到副屏或者系统缩放设置为150%时,取到的颜色总是鼠标左上方的一段距离,越靠近屏幕边缘偏移越大。
这个问题的根源是坐标系统的混用。Cursor.Position返回的是物理像素坐标,而WPF的Mouse.GetPosition返回的是DIP(设备无关像素)。在100%缩放时两者等价,但系统缩放设置为125%、150%、200%时,WPF窗口内的元素尺寸和屏幕物理像素的换算关系就会引入偏差。副屏在Windows里还有另一个坑:副屏的坐标可能包含负值区域,CopyFromScreen的Bounds如果只截PrimaryScreen,副屏的负坐标位置就取不到正确颜色。
解决标准做法是:用System.Windows.Forms.Screen.AllScreens计算所有屏幕的并集区域VirtualScreen,截屏时以虚拟屏幕左上角为原点,把鼠标坐标减去虚拟屏幕原点后再映射到位图坐标。我的代码里直接用SystemInformation.VirtualScreen来获取这个矩形,然后在PickColorAt里做坐标偏移换算,问题一次性解决。再提醒一句:如果你的程序要在高DPI显示器上部署,app.manifest里必须声明dpiAware,否则系统会对整个界面做位图拉伸,坐标映射全部错乱。
4.3 现象三:锁定颜色后取色器自己消失了
这个现象很迷惑:按下F8锁定后,颜色值已经出来了,但取色窗口自己不见了,或者主面板被系统弹到了任务栏后面。
原因其实不复杂:取色窗口是透明置顶的,锁定后我就把Topmost改成了False,同时主面板如果这时不是活动窗口,它会被系统压到Z序底部。而在用户角度,锁定的动作意味着“接下来我要仔细看这个颜色详情”,窗口突然消失确实很反直觉。
解决方式也很直接:锁定时不取消置顶,而是把透明窗口切换成完全不透明并保持置顶,仅当用户点击“收起”按钮时才取消置顶。有些取色器的做法是锁定时弹出一个独立的色值预览卡片,主界面保持不变。如果你自己做这个功能,我建议锁定和收起拆成两个独立交互,千万别让锁定动作附带窗口关闭的副作用。
4.4 现象四:全局快捷键被其他软件抢占,按下没反应
F8被其他软件(比如某些视频播放器、语音工具)占用了,按下没有取色反应。基于低级键盘钩子的实现原理上不会注册独占快捷键,所以不会弹“按键已被占用”的提示,但它会和目标软件冲突:如果目标软件在前台并且它注册的是全局组合键,我的钩子回调里检测到F8按下后会执行取色动作,此时目标软件也响应,出现两个动作同时发生。
另外有个容易忽略的坑:钩子回调里如果调用了MessageBox或者任何会阻塞的API,整个输入系统都会被卡住,表现为所有键盘输入都变慢或丢失。
解决方法是:在配置文件中开放快捷键自定义,默认F8/F9不用,改为Ctrl+Alt+Shift+C这类组合键降低冲突概率。同时钩子回调里严格只做“记录按键事件”,所有UI更新都通过Dispatcher.BeginInvoke排队执行,保证回调返回速度。如果发现快捷键经常无响应,把其他软件的全局热键列表检查一遍基本都能定位到元凶。
每条坑都亲历过之后,我的个人经验是取色器这类工具的性能瓶颈往往不在取色算法本身上,而在于事件调度和消息循环的合理性。保持回调轻量、缓存截屏、区分坐标系统,这三个原则贯穿始终就能解决80%的怪问题。
5. 让它变成你的颜色管理工具:从拾色器到调色板工作台的改造路线
5.1 路线一:加入取色历史记录与剪贴板推送
目前Demo锁定的颜色只能手动复制,改造一个ColorHistoryService,每次取色完成就写入一个ObservableCollection<ColorData>,用ListBox展示历史色块。实现上有个细节:历史记录要存储的是“颜色值 + 取色时的屏幕坐标 + 取色时间”三份数据,而不是只存一个色值,否则回溯颜色时不知道它从哪取来的。剪贴板推送可以用System.Windows.Forms.Clipboard.SetText,但要注意WPF项目里调用它需要设置线程为STA模式,否则会抛异常。
改造建议:
public class ColorHistoryEntry { public ColorData Color { get; set; } public Point ScreenPosition { get; set; } public DateTime PickedAt { get; set; } }这个实体可以在后续导出为JSON配色方案时直接序列化。
5.2 路线二:增加颜色对比度检查与无障碍预览
前端开发对颜色的认知不能停留在“好看”,还要考虑可读性。给这个Demo增加一个ContrastChecker,输入前景色和背景色,实时计算WCAG对比度。对比度计算的关键是把RGB转为线性亮度再取比值,建议直接封装一个小函数。界面布局上,在锁定颜色后显示三档结果:AAA级(大文本可用)、AA级(普通文本可用)、Fail(不满足),这个功能对设计系统搭建非常实用。
实现不复杂,核心就是亮度公式和对比度公式,代码量不到50行,收益却直观。做完这个,工具就从“取色器”升级成了“配色验证器”。
5.3 路线三:把调色板导出成CSS变量或Tailwind配置
对前端实用性最高的改造就是把颜色导出成可直接粘贴的代码。思路是锁定多个颜色后,生成一组带语义的变量名,输出为CSS自定义属性或者TypeScript常量。我的做法是增加一个“导出”按钮,弹出一个文本预览框,内容形如--brand-primary: #2F6BFF; --brand-danger: #D93025;,再给每个变量名配一个可编辑的输入框。这样设计系统和实际代码之间就形成了一个闭环,不用每次改了色值再手动替换十几个文件。
5.4 路线四:接入截图取区域色平均
取色器只能取一个点,但实际工作中经常需要读取一个按钮的底色,这个按钮可能有渐变或纹理,点哪个位置都会有色差。改造方案是让取色器支持按住Shift拖拽选区域,然后对区域内所有像素做算术平均得到主色。这个功能在ScreenCaptureService里加一个PickAverageColor(Rectangle area)方法,用已有截屏缓存遍历区域内每个像素即可。实践下来,区域大小控制在5x5到20x20之间最实用。
从那以后我每次改这类工具,都会先跑一遍坐标映射测试和DPI切换测试,再决定要不要加新功能。取色器这类项目的魅力就在于,它永远有折腾空间,增加一个吸管工具的实用性比最初想象的要大得多。希望这些经验能帮你少走弯路,可以顺利跑起来、改出你自己顺手的那一版。
本文还有配套的精品资源,点击获取