简介:面向C#桌面应用开发初学者的颜色选择器项目,聚焦屏幕取色、颜色模型转换与刻度尺调色等实用功能。项目依托Windows Forms框架,通过PictureBox捕获屏幕图像、GetPixel读取像素颜色,并借助TrackBar实现HSV分量调节,完整展示了C#事件驱动编程在GUI工具中的落地方式。压缩包大小约2.07MB,整体为轻量的可直接运行项目,便于下载后快速体验取色流程。目前已有813人学习下载,适合正在学习WinForms、System.Drawing及颜色体系(RGB/HSV/CMYK)的开发者参考。代码从界面布局到颜色拾取、实时预览与刻度尺交互均有清晰呈现,可帮助读者理解如何组织事件处理器、同步UI更新,并掌握将Color结构转换为不同颜色模型的思路,是一份能边读边练的C#入门实践资料。 写颜色选择器这件事,我一直觉得是C#入门到进阶之间一个特别好的跳板。很多朋友一提到“C#做的颜色选择器”就觉得是个玩具项目,其实真往深了做,里面涉及屏幕取色、DPI缩放适配、颜色空间转换、GDI+绘图,甚至和上位机、机器视觉流程联动时还有很多门道。我当年是在给工控设备写上位机工具时,发现系统自带的取色软件在150%缩放的工控屏上取色总是偏,一怒之下自己动手写了一个,之后越写越顺手,干脆把它做成了一个通用组件,UI配色、调试辅助、视觉ROI框参数标定都在用它。这篇文章就把我的完整实现思路、核心代码、踩过的坑都拆开讲一遍,希望能帮到正在纠结怎么做颜色选择器的朋友。
1. 为什么我最终选择自己写一个颜色选择器
1.1 现成工具解决不了的边界需求
说实话,市面上现成的取色工具并不少,浏览器插件、截图工具的拾色器、系统自带的画图工具都能看颜色值。但在实际项目里,我遇到的场景往往比“看一眼颜色值”要复杂得多。比如给上位机软件做主题配置时,需要按HSL色相区间挑选一组相近颜色,现成工具不能直接调H/S/L滑块;再比如做机器视觉流程调试时,需要把某个像素颜色实时回传到Halcon或VisionMaster的变量里,工具取了色却没法和我的业务代码联动;还有一次在工业触摸屏的远程桌面上用系统自带的颜色面板,窗口缩放缩放,取出来的颜色坐标完全对不上。
这些情况都指向一个问题:工具能解决“取一个颜色”的问题,却解决不了“取到颜色之后做什么”的问题。自己写一个C#颜色选择器,最大的好处就是可控。想加什么功能直接改代码,想嵌进哪个窗口就嵌进哪个窗口,取回来的颜色要不要同时复制成RGB、Hex、HSL、颜色名称,都可以自定义。
1.2 功能清单和界面布局的取舍
动手之前我先列了一个“必须有”和“可以有”的清单,这个步骤很关键,不然写着写着就忍不住加功能,项目就没完没了了。
必须有:
- 鼠标在屏幕上任意位置取色,实时显示RGB和Hex。
- 颜色预览区域可以单击锁定,方便放大小区域内观察。
- 支持HSL模式调整,用滑块微调色相、饱和度、亮度。
- 一键复制色值到剪贴板。
可以有:
- 最近取色记录列表,方便来回对比。
- 自动计算反色和高对比度字体色。
- 快捷键全局取色(即使窗口不在前台也能调用)。
界面布局我采用了非常经典的三段式:顶部是颜色预览大色块和取色按钮,中间是RGB/Hex/HSL的数值显示和滑块,底部是最近颜色历史列表。开发框架我用的是WinForms,原因后面会讲。这个布局在屏幕上大概占400x600像素,做成一个小窗放在屏幕角落,日常使用不遮挡视野。
2. 关键原理:屏幕取色与颜色空间,做对了才能不偏色
2.1 屏幕取色的底层逻辑:CopyFromScreen与GetPixel
WinForms里屏幕取色的标准做法是调用Graphics.CopyFromScreen把屏幕上某个点的像素拷贝到位图,再用Bitmap.GetPixel读出颜色。整套流程看起来简单,实际有几个“为什么”值得说清楚。
第一,为什么不用Graphics.FromHwnd配合GetPixel?因为GDI+的Graphics对象默认不支持直接在屏幕上读取像素,必须通过位图作为中介。第二,GetPixel本身性能并不好,它每调用一次都要做一次格式转换和内存锁定,如果你用循环去连续取色,大概每秒几十次就是极限了。后面我会讲怎么用LockBits优化连续取色场景。
核心取色代码是这样的:
public static Color GetPixelColor(Point screenPoint) { using (var bitmap = new Bitmap(1, 1, PixelFormat.Format32bppArgb)) { using (var graphics = Graphics.FromImage(bitmap)) { graphics.CopyFromScreen(screenPoint.X, screenPoint.Y, 0, 0, new Size(1, 1)); } return bitmap.GetPixel(0, 0); } }这段代码在低分辨率屏幕上看起来没问题,但一旦放到高分屏或150%缩放的工控屏上,就会遇到坐标偏移问题。原因很简单:CopyFromScreen操作的是物理像素坐标,而WinForms里的鼠标坐标是逻辑坐标,如果程序没有做DPI感知,这两个坐标在缩放屏幕上会差一个缩放系数。这是踩坑重灾区,后面专门开一节讲。
2.2 RGB到HSL/HSV转换:把“人类语言”翻译给调色板
做颜色选择器如果只用RGB,你会发现微调颜色特别别扭。RGB是给机器看的颜色模型,改变一个通道的值,肉眼看到的颜色变化并不线性。而HSL(色相、饱和度、亮度)更接近人类的视觉感知,调H是换颜色,调S是变浓淡,调L是变明暗,这种操作方式在做UI主题调试时特别顺手。
RGB转HSL的公式不算复杂,但网上很多版本的实现边界条件有误。我总结的可靠版本是这样的:
public static (float H, float S, float L) RgbToHsl(int r, int g, int b) { float rn = r / 255f; float gn = g / 255f; float bn = b / 255f; float max = Math.Max(rn, Math.Max(gn, bn)); float min = Math.Min(rn, Math.Min(gn, bn)); float delta = max - min; float h = 0f, s = 0f, l = (max + min) / 2f; if (delta > 0) { s = l > 0.5f ? delta / (2f - max - min) : delta / (max + min); if (max == rn) { h = (gn - bn) / delta + (gn < bn ? 6f : 0f); } else if (max == gn) { h = (bn - rn) / delta + 2f; } else { h = (rn - gn) / delta + 4f; } h *= 60f; } return (h, s, l); }这里有个细节:判断色相落在哪个区间时,顺序必须是R、G、B,而且当max为R色且G分量小于B分量时,要加360度(也就是代码里的gn < bn ? 6f : 0f),否则算出来的色相会出现负角度,导致后续滑块显示不正确。
HSL转RGB则要注意,色相h可能为0到360度,先除以60得到区间,再用floor和取模来判断落在哪个颜色分量区间,逐段计算得到R、G、B。这些公式在各种计算机图形学教材里都有,但真正手写一遍并配合滑块验证,你会对颜色模型有更深的理解。
2.3 你容易忽略的DPI缩放问题
这一节是干货中的干货。很多人写的颜色选择器在普通笔记本上跑得好好的,到了150%缩放的工作站上就开始取偏,而且偏得还很规律:取到的颜色总是位于鼠标位置左上方一点。这个偏移量等于缩放系数乘以鼠标坐标减去某个常量,其实全是DPI感知没有设置好。
解决办法分两步走。第一,在程序清单文件app.manifest中声明DPI感知;第二,在代码里使用GetDpiForWindow动态获取当前窗口所在屏幕的DPI,而不是写死96。
manifest里加这一段:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application>然后在做屏幕坐标转换时,不要用Cursor.Position直接去CopyFromScreen,要先把逻辑坐标转成物理坐标:
public static Point LogicalToPhysical(Point logicalPoint, IntPtr windowHandle) { var dpi = GetDpiForWindow(windowHandle); float scale = dpi / 96f; return new Point((int)(logicalPoint.X * scale), (int)(logicalPoint.Y * scale)); }GetDpiForWindow是Win10 1607之后才提供的API,在Win7和旧系统上回退到96即可。实测下来,加了这步之后,从100%到200%缩放的屏幕上取色都不会有肉眼可见的偏移。
3. 实操实现:一个能跑起来的C#颜色选择器
3.1 工程结构与界面准备的细节
我用的是WinForms,选它的原因很实在:部署简单、系统自带、在工控机上兼容性好。WPF在动画和样式上确实更漂亮,但一个颜色选择器小工具用不到那么重的渲染框架,WinForms的Panel边刷色边显示已经足够流畅了。
新建项目后,主窗体Form1上放这些控件:
panelColorPreview:颜色预览大色块,显示当前选中的颜色。btnPick:开始取色按钮。txtR、txtG、txtB:RGB数值显示。txtHex:十六进制色值显示。trackH、trackS、trackL:三个HSL滑块,范围分别是0-360、0-100、0-100。flowLayoutPanelHistory:历史颜色记录区。
初始化的时候要把窗体的KeyPreview设置为true,这样后面按Esc退出取色模式才会生效。
3.2 取色核心代码与连续拾色优化
取色交互我设计了两种模式:单击取色和长按跟随取色。单击模式是点击“取色”按钮后,鼠标变成十字光标,移动鼠标时绘制一个放大镜跟随窗口,点击左键锁定颜色。长按跟随模式则是在按住左键的同时实时更新颜色值,松开鼠标结束。
跟随取色的核心是一个Timer控件,间隔15毫秒刷新一次。为什么不直接在MouseMove事件里取色?因为MouseMove触发的频率太高,如果每次都用GetPixel,界面会卡顿。用Timer做帧率限制,15毫秒大概能跑60帧,足够流畅且不浪费CPU。
private void timerPick_Tick(object sender, EventArgs e) { var screenPoint = Cursor.Position; var physicalPoint = LogicalToPhysical(screenPoint, this.Handle); var color = GetPixelColor(physicalPoint); UpdateColorDisplay(color); }如果只是偶尔取一个颜色,GetPixel没有问题。但如果要把这个控件嵌到视频流分析工具里,比如实时标注某个运动目标的颜色,那GetPixel的性能就不够看了。这时候要用LockBits一次性锁定整块区域的内存,然后直接用指针读取像素值:
public static unsafe Color GetPixelFast(Bitmap bmp, int x, int y) { var rect = new Rectangle(0, 0, bmp.Width, bmp.Height); var bmpData = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); try { byte* ptr = (byte*)bmpData.Scan0; int offset = y * bmpData.Stride + x * 4; return Color.FromArgb(ptr[offset + 3], ptr[offset + 2], ptr[offset + 1], ptr[offset]); } finally { bmp.UnlockBits(bmpData); } }Format32bppArgb在内存中的字节顺序是B、G、R、A,别搞反了,不然取出来的颜色偏蓝偏橙就会很莫名其妙。项目中需要unsafe编译选项,在工程文件里加<AllowUnsafeBlocks>true</AllowUnsafeBlocks>。
3.3 颜色展示、格式转换与剪贴板交互
颜色展示这块,除了把Color赋给panelColorPreview.BackColor,我还会自动计算一个高对比度的前景色用来显示色值文字。计算公式用亮度感知权重:
public static Color GetForegroundColor(Color bg) { double luminance = 0.299 * bg.R + 0.587 * bg.G + 0.114 * bg.B; return luminance > 128 ? Color.Black : Color.White; }为什么是这三个权重?因为人眼对绿色最敏感,对蓝色最不敏感,所以相同亮度值的绿色看起来比蓝色亮得多。直接用(R+G+B)/3算平均亮度会遇到纯蓝背景上放黑色文字看不清的问题。这个公式在印刷行业和UI设计里都广泛使用,作为前景色判断非常可靠。
Hex的转换也有一点讲究。ColorTranslator.ToHtml(color)是自带的转换方法,但它可能会把颜色名转成名称(比如Red),而不是输出#FF0000。为了保证一定是六位Hex,我自写了转换:
public static string ToHex(Color color) { return $"{color.R:X2}{color.G:X2}{color.B:X2}"; }注意用大写X2,确保输出长度统一为6位,小写x的位宽也不足2时会补零,大写更规范。复制到剪贴板用Clipboard.SetText($"#{ToHex(color)}"),同时可以在txtHex里显示不带井号的版本,方便直接用于WinForms的ColorTranslator.FromHtml。
HSL滑块联动也不复杂,三者之一变化时用HSL转RGB函数重新生成颜色,再同步更新RGB显示和预览色块。这里要防止事件循环触发,我加了一个_isUpdating标志位,在同步值的时候暂时挂断事件:
private bool _isUpdating = false; private void UpdateColorDisplay(Color color) { _isUpdating = true; txtR.Text = color.R.ToString(); txtG.Text = color.G.ToString(); txtB.Text = color.B.ToString(); txtHex.Text = ToHex(color); panelColorPreview.BackColor = color; panelColorPreview.ForeColor = GetForegroundColor(color); var (h, s, l) = RgbToHsl(color.R, color.G, color.B); trackH.Value = (int)Math.Round(h); trackS.Value = (int)Math.Round(s * 100f); trackL.Value = (int)Math.Round(l * 100f); _isUpdating = false; }事件处理里看到_isUpdating == true就跳过,这种保护在处理多个关联控件互相刷新时特别重要,忘了加会看到一个死循环导致界面假死,别问我是怎么知道的。
4. 上线前必须处理的4个坑
4.1 屏幕边缘取色失效
取色时鼠标移到屏幕边缘,CopyFromScreen会尝试拷贝超出屏幕范围的坐标,这时系统不会报错,但会返回全黑的颜色。很多人会误以为取到了“黑色”,实际上那个位置根本就是无信号的区域。解决办法是对坐标做边界裁剪:
var bounds = Screen.FromPoint(Cursor.Position).Bounds; int safeX = Math.Max(bounds.Left, Math.Min(Cursor.Position.X, bounds.Right - 1)); int safeY = Math.Max(bounds.Top, Math.Min(Cursor.Position.Y, bounds.Bottom - 1));用bounds.Right - 1和bounds.Bottom - 1是因为屏幕坐标是从0开始的,Right本身已经是超界值。
4.2 窗口置顶、光标隐藏与用户体验细节
在做跟随取色时,如果主窗体本身挡住了要取色的目标区域,体验会很差。我的处理是取色时把窗口透明度降到0.2,同时设置TopMost = true,这样既能看到要取的位置,窗口又不会完全遮挡。取色过程中还要把鼠标光标隐藏,否则光标本身可能会覆盖住目标像素。用Cursor.Hide()在取色开始调用,结束时Cursor.Show()恢复。
还有一个容易被忽略的细节:取色过程中如果用户按了Esc键,要能够随时取消取色模式并把透明度恢复。直接在KeyDown事件里判断e.KeyCode == Keys.Escape,把定时器停止、透明度恢复,这套流程一定要做干净,否则窗口可能一直处于半透明置顶状态,给用户留下“程序有bug”的感觉。
4.3 与上位机软件集成时的注意点
如果你的颜色选择器不只是独立小工具,而是要做成上位机软件里的一个子模块,有几个集成层面的问题需要提前想清楚。
第一,取色操作会阻塞UI线程吗?如果取色逻辑放在Timer或后台线程里就没事,但如果是同步点击按钮,等待用户点击屏幕锁定的那种交互,就得用ShowDialog或模态窗口,不能在主业务线程里死等。第二,取到的颜色怎么回传给业务代码?我建议通过事件而不是直接改全局变量:
public event EventHandler<ColorChangedEventArgs> ColorChanged; public class ColorChangedEventArgs : EventArgs { public Color SelectedColor { get; set; } }主程序订阅这个事件,在颜色变化时更新自己的UI或视觉算法的参数。这样工具和业务解耦,以后换成WPF界面或者改成Web端调用,都不用改取色核心逻辑。
第三,如果要在工业视觉软件(比如VisionMaster、Halcon)里做ROI区域的颜色筛选,颜色选择器可以作为辅助工具,把选中的HSL范围直接输出成算法需要的阈值参数。这时候HSL转换的精度就非常重要,因为工业光源下色相偏移哪怕是几度,都可能导致检测结果出现大量误检。
5. 进一步扩展:从小工具到通用调色组件
5.1 如何把颜色选择器内嵌进你的主项目
把颜色选择器打包成UserControl后,就能直接拖到上位机软件的主界面上。当时我做的是在项目里新建一个UserControl,把主窗体的所有逻辑全部迁移过去,在外部调用时只需要监听ColorChanged事件。这样每次调整界面微调颜色,不再需要打开外部取色工具,整个调整流程缩短到原来的三分之一。
主要代码变成这样:
public partial class ColorPickerControl : UserControl { public event EventHandler<ColorChangedEventArgs> ColorChanged; public Color SelectedColor { get => _selectedColor; set { _selectedColor = value; UpdateColorDisplay(value); } } }外部使用方只需要实例化控制、设置初始颜色、订阅事件,剩下的交互细节全部隐藏在控件内部。这种封装方式让颜色选择器从“一个程序”变成了“一个组件”,复用率大大提升。
5.2 后续可以加上的实用功能
我的颜色选择器现在还在持续迭代,目前计划中的功能有三个比较实用。
一个是调色板文件导入导出。上位机软件开发时经常会遇到需要批量设置按钮颜色、警告色、正常运行色等场景,把颜色列表保存成JSON或CSV文件,在项目里直接载入,能省去手工拷贝的重复劳动。另一个是相近颜色推荐。在HSL色相不变的情况下,自动生成饱和度渐变的一组颜色,这对设计软件配色特别有用。还有一个是全局快捷键取色。即使颜色选择器窗口被其他软件遮挡,只要按一个预定义的热键,就能激活取色模式并复制颜色值,这对频繁跨窗口取色的场景帮助很大。
写到这里,想把我在这个项目里最真实的体会分享出来。颜色选择器这个项目表面上看功能简单,但它串起了WinForms、GDI+、DPI感知、颜色模型转换、事件驱动编程好几个关键知识点,对C#初期学习者的帮助比刷一堆理论教程实在得多。我做这个工具时最大的收获,是真正理解了DPI为什么会让桌面应用看起来“明明代码没错却到处出错”,也理解了HSL在视觉相关场景下为什么比RGB好用。后来做上位机、做视觉匹配调试时,这个工具一直是我的标配。如果你也在准备做一个类似的小工具,我的建议是:先按照上面几步把最基础版本跑通,之后再根据你自己项目的特殊需求往里面加功能,它很快就不再只是“练手项目”,而是会变成开发环境里真实的提效利器。
本文还有配套的精品资源,点击获取