首页 / 资讯中心 / 文章详情

Real Life Color Picker:浏览器摄像头实时取色器实现指南

Real Life Color Picker:浏览器摄像头实时取色器实现指南 ★ FEATURED ARTICLE
我一直觉得屏幕取色器解决不了生活的真实需求。你面前放着一块毛绒面料或者一整面墙的乳胶漆屏幕上的颜色永远隔着一层。所以我把取色器的思路搬到了真实世界一个完全跑在浏览器里的 Real Life Color Picker打开摄像头点一下就出颜色。不需要装 App不需要注册一个 HTML 文件就能跑。取色结果同时给出 HEX、RGB、HSL 三个格式还有一个人性化的颜色名称和取色历史记录帮你快速完成颜色收集、比色与配色。对于设计师、装修业主、穿搭博主、绘画爱好者甚至只是想知道自己口红色号的人来说这都算得上一个实用的口袋工具。1. 项目定位为什么需要现实世界取色器1.1 屏幕取色的边界在哪用过 PS 吸管工具或者浏览器里像素级取色插件的人都知道取色器解决的是屏幕上的颜色问题。但现实世界是另一个维度地上的瓷砖、身上的外套、桌上的橘子它们的颜色信息分散在你生活的各种物理材质上。屏幕取色和现实取色之间有本质差异——屏幕颜色是 RGB 设备色空间的呈现而现实物体的颜色是物体表面反射光谱与光源共同作用的结果。同一个物体在正午阳光下和傍晚灯光下给人眼的颜色感知完全不同。我有一次帮朋友挑窗帘拍了张照片在手机上调色怎么都觉得偏色。后来才意识到问题出在照片的白平衡上照片并不能还原我眼睛看到的真实颜色。Real Life Color Picker 恰恰是做这件事的把现实物体的颜色数字化让你能拿到一个可以在设计软件里直接复用的颜色值。它解决的是屏幕之外的那部分取色需求。1.2 解决什么场景下的痛点我知道很多人第一反应是这种东西到底有什么用。说几个实际场景你感受下。第一个是装修选漆。你看到邻居家的客厅墙面颜色很好看想照着刷自己家但人不可能把邻居家墙皮抠下来拿到店里去调色。掏出手机打开取色器对准墙面一按HEX 色号出来了去油漆店拿色号调色误差可以控制到非常小。这个场景里取色器等于把视觉印象转换成了可沟通的颜色语言。第二个是穿搭跟色。看到博主穿的上衣颜色特别显白想知道具体是什么色拿取色器对准屏幕或直接对准衣服就能拆出色号照着买同色系。这个需求在女生群体里反馈尤其热烈因为大多数人对颜色的描述还停留在天蓝色姜黄色这种模糊词汇上取色器提供的是精确值。第三个是绘画写生和设计。画水彩要听色、设计海报要提取照片的主色调取色器对准实物就能把颜色吸出来存储成自己的色板。对设计师来说这是收集灵感的最好方式——看到什么好看的配色先吸进来以后做方案直接用。第四个是色弱人群辅助。色弱人群往往分不清相近色差有一个可以报出颜色名称和具体色值的工具能在日常生活中起到很大的帮助作用。虽然这个工具不能替代专业配色仪器但在日常的这个颜色是什么色这个判断上已经比肉眼靠谱多了。1.3 技术方案选型为什么是浏览器而非 App我为什么要用浏览器加 JavaScript 而不是做一个原生 App核心原因是零门槛分发。原生 App 要编译、签名、上架用户要下载安装光这一步就劝退了大多数人。而一个网页链接发过去对方打开就能用不需要任何环境配置。在微信群里发个链接朋友们直接点开就玩到了。另一个原因是 Web 平台对摄像头和像素操作的支持已经很成熟getUserMedia 负责摄像头流Canvas 负责帧数据和像素读取Clipboard API 负责复制颜色值整个链路不需要任何第三方库。你只需要会 HTML、CSS、JavaScript就能在一天之内把这个项目从零跑起来。如果换成 Python 方案比如 OpenCV 加摄像头它确实能做颜色分析但你需要一台装好环境的主机摄像头不能即开即用更适合离线批处理而不是即时的生活场景。而浏览器方案天然跨平台手机上、pad 上、电脑上打开同一个网页体验完全一致。唯一的硬性限制是必须用 HTTPS 或 localhost 访问这个问题后面专门讲。2. 核心原理解析摄像头画面如何变成颜色值2.1 从视频流到像素整体链路整个链路可以拆成四个环节每一步都不复杂但组合起来就完成了现实到数据的转换。首先用 getUserMedia 打开摄像头拿到视频流接到 video 元素上让画面显示出来。然后每一次取色时把当前视频帧 drawImage 到一块 canvas 上。接下来通过 getImageData 读取点击坐标对应的像素得到一个 RGB 值。最后把 RGB 转换成 HEX、HSL并在内置的常用颜色表中做最近邻匹配得到颜色名称并展示。听起来像流水线实际操作也确实是流水线。这里有一个非常关键的理解点视频帧和画布像素的关系。video 元素里的每一帧画面本质上就是一个像素矩阵canvas 把视频帧复制过来之后你就可以按 (x, y) 坐标任意读取这个矩阵中的任意像素。这就好比你在看一幅拼好的拼图随时可以拆出任意一块单独看。整个项目的技术核心其实就是怎么从视频流里准确地抠出你想要的像素。2.2 摄像头接入getUserMedia 的参数与权限getUserMedia 是 Web 平台原生的媒体捕获接口调用它会弹出浏览器的权限授权框。其中 video 参数的常用配置包括 resolution 和 facingMode。默认情况下设备可能会选择一个顺手的摄像头比如笔记本电脑的前置摄像头而面向移动端用户我们希望优先使用后置所以可以显式传 facingMode: environment。需要注意的是这是倾向性提示而不是强制指令设备没有后置摄像头时会退回到可用摄像头。另一个实际细节是打开视频流后不要立刻抓帧必须等待 video 的 loadedmetadata 事件触发此时 videoWidth 和 videoHeight 才是真实的分辨率。如果提前绘制canvas 尺寸还是默认的 300x150会导致像素坐标全部错乱。这一步看起来不起眼但恰恰是很多新手项目为什么取不到颜色的第一个坑。我见过好几个人卡在这个问题上界面是黑的或者画质很糊其实都是没有等元数据到位。2.3 坐标换算点得准颜色才准拿到视频帧之后要解决一个问题视频帧的坐标和页面上的点击坐标之间是什么关系很多人直接做线性映射但视频元素如果用了 object-fit: cover 这种 CSS 样式画面会进行裁剪直接线性映射就会产生系统误差——越靠近边缘点得越不准。为什么 cover 会这样因为 cover 是在保持纵横比、完全覆盖容器的前提下做到的缩放后必然会在某一方向产生溢出裁剪。视频原始分辨率和容器尺寸的宽高比不一样时你看到的画面只是完整视频帧的一部分。举个例子视频是 16:9容器是手机竖屏的 9:19.5cover 模式下水平方向会被大大裁剪只显示画面中间的一条。如果不计算这层裁剪偏移点击位置和实际取色位置必然对不上。我提供一个通用的换算函数这个函数我建议每个做视频交互的前端都收藏一份。它先算出视频在容器中的实际显示区域再用这个显示区域做坐标映射这样无论视频比例、容器比例怎么变都不会错。2.4 RGB 到 HEX / HSL / 颜色名称的转换颜色格式转换是纯数学问题但有三个细节值得说。HEX 是 RGB 的十六进制序列化每个通道从十进制转成两位十六进制不足两位前面补 0。比如 RGB(255, 87, 51) 转出来就是 #FF5733。这个转换虽然简单但要注意大小写规范——前端工程里一般用大写 HEX阅读更清晰。HSL 的转换相对复杂一点它把颜色拆成色相 / 饱和度 / 明度三个维度更符合人眼的感知。计算公式涉及归一化、最大通道与最小通道差值然后分三个分支计算角度。算法的核心是判断哪个通道是最大值然后按公式带角度偏移。因为找不到比直接展示公式加代码实现更直观的方式我会把完整函数写进后面的代码区。颜色名称是最体现产品巧思的功能。你不需要把颜色看出来工具直接告诉你这是雾霾蓝还是砖红色。实现上我内置了一张常见颜色表包含五十多种常见色每个色有一个参考 RGB 值匹配算法就是遍历这张表计算当前颜色与每个参考色之间的欧几里得距离距离最小的就是最接近的颜色。三十行代码解决一个看起来很高大上的需求这种小工程大体验的功能正是独立开发者该重点投入的部分。3. 完整实现一个万能的 Real Life Color Picker3.1 页面结构与布局我做的页面采用全屏视频 底部信息面板的布局视频铺满整个屏幕作为取色画布底部是半透明的毛玻璃面板展示颜色预览、三个格式的色值、颜色名称和最近的历史记录。点击画面的任意位置即可取色。视频用 object-fit: cover 确保填满屏幕。为了移动端播放顺畅video 上加了 autoplay、playsinline、muted 三个属性。playsinline 非常关键iOS Safari 上不加它播放视频会被全屏接管取色器就没法用了。底部信息面板我用了 backdrop-filter 做毛玻璃效果在移动端视觉上很现代而且不会挡住视频主体内容。3.2 取色核心逻辑与放大镜主逻辑一共三块初始化摄像头、点击取色、实时放大镜预览。初始化代码我已经在上面说了现在说取色事件。我监听 video 元素的 click桌面端和 pointerup移动端触摸通过坐标换算函数把事件坐标转成视频帧坐标再做边界保护防止点击视频边缘越界然后用绘制当前帧并读取像素的逻辑拿到颜色值。更好的交互是加一个圆形放大镜手指移动时放大镜会显示取色点周围放大了 3 倍的画面中心有十字准线方便你精确对准想取色的位置。这个放大镜实际上是一个独立的圆形 canvas每次移动时从视频帧里抠出一小块区域放大绘制同时用 CSS 定位到手指附近。由于每次移动都要重绘这里必须用 requestAnimationFrame 做节流否则移动端会卡。我做第一版的时候没有节流iPhone 上滑一下能掉十几帧加了之后流畅多了。3.3 增强功能历史记录、复制、颜色命名历史记录用数组存储每次取色 unshift 到头部最多保留 20 条渲染成底部一排小色块。点击历史色块同样能恢复完整的色值信息。这个功能带来的体验提升非常明显——取色器最常被吐槽的一点就是刚才那个颜色找不到了。有了历史记录你连续取好几个颜色可以慢慢对比不会丢。复制功能我用了 Clipboard API但考虑到部分旧设备兼容性我还写了个降级方案——动态创建 textarea选中执行 execCommand(copy)。实测在绝大多数浏览器上都能成功。这里有个坑Clipboard API 在非安全上下文HTTP下是不存在的所以降级方案不是可选项而是必须写。颜色命名的具体实现见 2.4这里不多讲但我要强调一点这个功能一定不要做成教科书式的精确色名系统那样反而没人用。用户需要的不是International Klein Blue这种专业名字而是深蓝“雾霾蓝这种日常语言。3.4 提高取色精度的 5 个细节第一个细节是单像素采样容易被噪点干扰我建议改用点击位置周边 5x5 区域取平均色得到一个更稳定的颜色值。现实中物体表面总有纹理和灰尘单像素很容易取到突兀的杂色。第二个是 canvas 上下文要设置 willReadFrequently: true这是给浏览器优化的提示——因为我们要高频调用 getImageData不加这个 flag部分浏览器会走 GPU 路径导致每次读取都卡一下。第三个是设备白平衡。摄像头会自动调整曝光和白平衡导致同一物体在不同光线下取出的颜色不一致。这个问题浏览器层很难完全控制只能在产品层做提示尽量在均匀光线下取色避免强光直射和阴影交界处。第四个是屏幕色彩校准。如果你经常做色彩相关工作建议在常用设备上用校色仪做一次屏幕校色避免设备屏幕显示出来的颜色和取出来的色值不一致的困惑。这个问题的本质是设备色域差异很难靠纯软件解决。第五个是桌面端可以增加键盘交互。按空格键锁定当前画面箭头键微调取色点回车确认取色——这会让精确定位的体验上一个台阶。移动端没有键盘但双击可以锁定画面暂停视频也可以达到类似效果。3.5 完整代码可直接运行的 Real Life Color Picker我直接给出一个完整的 HTML 文件这是经过简化但能跑通全流程的版本。使用方式保存为 html 文件在 localhost 或 HTTPS 环境下打开。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleReal Life Color Picker/title style * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: #000; } #video { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: cover; } #previewCanvas { position: fixed; width: 120px; height: 120px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.9); pointer-events: none; display: none; box-shadow: 0 4px 20px rgba(0,0,0,0.4); } #panel { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; background: rgba(255,255,255,0.92); backdrop-filter: blur(20px); border-radius: 20px 20px 0 0; padding: 20px 24px 30px; box-shadow: 0 -6px 30px rgba(0,0,0,0.15); } #colorPreview { width: 48px; height: 48px; border-radius: 12px; border: 2px solid #e0e0e0; } .codes { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-top: 10px; } .code-chip { background: #f0f0f0; padding: 4px 10px; border-radius: 8px; font-size: 13px; font-family: monospace; cursor: pointer; } #history { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .h-item { width: 30px; height: 30px; border-radius: 8px; cursor: pointer; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.2); } #colorName { font-size: 14px; color: #333; margin-top: 8px; font-weight: 600; } /style /head body video idvideo autoplay playsinline muted/video canvas idpreviewCanvas width120 height120/canvas div idpanel div styledisplay:flex; gap:14px; align-items:center; div idcolorPreview/div div div idcolorName点击画面取色/div div classcodes idcodes/div /div /div div idhistory/div /div script const video document.getElementById(video); const captureCanvas document.createElement(canvas); const captureCtx captureCanvas.getContext(2d, { willReadFrequently: true }); const previewCanvas document.getElementById(previewCanvas); const previewCtx previewCanvas.getContext(2d); const history []; let rafId null; async function init() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert(当前浏览器不支持摄像头 API请使用最新版 Chrome / Safari / Edge); return; } try { const stream await navigator.mediaDevices.getUserMedia({ video: { facingMode: environment }, audio: false }); video.srcObject stream; await new Promise(resolve { video.onloadedmetadata resolve; }); captureCanvas.width Math.min(video.videoWidth, 1280); captureCanvas.height Math.round(captureCanvas.width * video.videoHeight / video.videoWidth); } catch (err) { console.error(err); if (err.name NotAllowedError) alert(摄像头权限被拒绝请在浏览器设置中允许访问); else if (err.name NotFoundError) alert(没有检测到摄像头设备); else alert(摄像头启动失败: err.message); } } function getVideoDisplayRect(videoEl) { const videoRatio videoEl.videoWidth / videoEl.videoHeight; const rect videoEl.getBoundingClientRect(); const containerRatio rect.width / rect.height; let displayW, displayH, offsetX, offsetY; if (containerRatio videoRatio) { displayH rect.height; displayW displayH * videoRatio; offsetX (rect.width - displayW) / 2; offsetY 0; } else { displayW rect.width; displayH displayW / videoRatio; offsetX 0; offsetY (rect.height - displayH) / 2; } return { displayW, displayH, offsetX, offsetY, rect }; } function getVideoCoords(clientX, clientY) { const { displayW, displayH, offsetX, offsetY, rect } getVideoDisplayRect(video); const relX clientX - rect.left; const relY clientY - rect.top; if (relX offsetX || relX offsetX displayW || relY offsetY || relY offsetY displayH) return null; const x Math.max(0, Math.min(Math.round((relX - offsetX) / displayW * video.videoWidth), video.videoWidth - 1)); const y Math.max(0, Math.min(Math.round((relY - offsetY) / displayH * video.videoHeight), video.videoHeight - 1)); return { x, y }; } function getAverageColor(ctx, x, y, radius 2) { const sx Math.max(0, x - radius), sy Math.max(0, y - radius); const sw Math.min(radius * 2 1, ctx.canvas.width - sx); const sh Math.min(radius * 2 1, ctx.canvas.height - sy); const { data } ctx.getImageData(sx, sy, sw, sh); let r 0, g 0, b 0, count 0; for (let i 0; i data.length; i 4) { r data[i]; g data[i 1]; b data[i 2]; count; } return { r: Math.round(r / count), g: Math.round(g / count), b: Math.round(b / count) }; } function rgbToHex(r, g, b) { return # [r, g, b].map(c c.toString(16).padStart(2, 0)).join().toUpperCase(); } function rgbToHsl(r, g, b) { r / 255; g / 255; b / 255; const max Math.max(r, g, b), min Math.min(r, g, b); let h, s, l (max min) / 2; if (max min) { h s 0; } else { const d max - min; s l 0.5 ? d / (2 - max - min) : d / (max min); switch (max) { case r: h (g - b) / d (g b ? 6 : 0); break; case g: h (b - r) / d 2; break; default: h (r - g) / d 4; } h / 6; } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; } const colorNameTable [ { name: 黑色, r: 0, g: 0, b: 0 }, { name: 白色, r: 255, g: 255, b: 255 }, { name: 灰色, r: 128, g: 128, b: 128 }, { name: 红色, r: 255, g: 0, b: 0 }, { name: 暗红, r: 139, g: 0, b: 0 }, { name: 橙色, r: 255, g: 165, b: 0 }, { name: 黄色, r: 255, g: 255, b: 0 }, { name: 金色, r: 218, g: 165, b: 32 }, { name: 绿色, r: 0, g: 128, b: 0 }, { name: 草绿, r: 124, g: 252, b: 0 }, { name: 蓝色, r: 0, g: 0, b: 255 }, { name: 天蓝, r: 135, g: 206, b: 235 }, { name: 紫色, r: 128, g: 0, b: 128 }, { name: 粉色, r: 255, g: 192, b: 203 }, { name: 棕色, r: 139, g: 69, b: 19 }, { name: 米色, r: 245, g: 245, b: 220 } ]; function findNearestColor(r, g, b) { let minDist Infinity, nearest colorNameTable[0]; for (const c of colorNameTable) { const dist Math.pow(r - c.r, 2) Math.pow(g - c.g, 2) Math.pow(b - c.b, 2); if (dist minDist) { minDist dist; nearest c; } } return nearest.name; } function pickColor(x, y) { captureCtx.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height); const scaleX captureCanvas.width / video.videoWidth; const scaleY captureCanvas.height / video.videoHeight; const color getAverageColor(captureCtx, Math.round(x * scaleX), Math.round(y * scaleY)); updateUI(color); addHistory(color); } function updateUI({ r, g, b }) { const hex rgbToHex(r, g, b); const hsl rgbToHsl(r, g, b); const name findNearestColor(r, g, b); document.getElementById(colorPreview).style.background hex; document.getElementById(colorName).textContent name; document.getElementById(codes).innerHTML span classcode-chip>
阅读完成 · 觉得有帮助?
咨询建站