Skip to content

实现 屏幕取色器 工具

分类:general | 标签:取色、颜色、EyeDropper 拾取屏幕颜色 HEX/RGB

2.1 功能说明

拾取屏幕颜色 HEX/RGB

2.1.1 使用指南

功能说明

调用浏览器原生取色器(EyeDropper)从屏幕任意位置拾取颜色,显示 HEX/RGB 并可复制;不支持的浏览器可用颜色输入兜底。

图片吸管

上传本地图片后渲染到画布,鼠标移动实时预览像素颜色,点击即可锁定取色(基于 getImageData 读取单个像素)。图片仅在浏览器内存中处理,不会上传。

主色调提取

对图片像素做 5 位/通道的直方图量化后,用加权 k-means 聚类出若干主色, 并按覆盖面积从高到低排序。可调整提取数量(默认 6 个),支持忽略近白/近黑像素, 避免大面积背景色淹没真正的主体色。算法确定性,同一张图多次提取结果一致。

导出

可将提取出的调色板导出为 CSS 变量文本或 HEX 色卡列表,一键复制到项目里使用。

使用场景

设计还原、取色比对、从参考图/截图中提炼配色方案。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • pick()
  • onPick()
  • copy()
  • toHex()
  • releaseUrl()
  • handleImage()
  • eventToPixel()
  • onCanvasClick()
  • onCanvasMove()
  • quantize()
  • dist2()
  • extractPalette()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const activeTab = ref('screen')

/* ---------------- 原有:屏幕取色 ---------------- */
const color = ref('#409eff')
const eyeSupported = ref(false)
onMounted(() => { eyeSupported.value = typeof window !== 'undefined' && 'EyeDropper' in window })

async function pick() {
  try {
    const ed = new window.EyeDropper()
    const res = await ed.open()
    color.value = res.sRGBHex
  } catch { /* 用户取消 */ }
}
function onPick(v) { if (v) color.value = v }

const rgb = computed(() => {
  const m = color.value.replace('#', '')
  const r = parseInt(m.slice(0, 2), 16), g = parseInt(m.slice(2, 4), 16), b = parseInt(m.slice(4, 6), 16)
  return `rgb(${r}, ${g}, ${b})`
})
function copy(v) {
  if (!v) return
  navigator.clipboard?.writeText(v)
  ElMessage.success('已复制')
}

/* ---------------- 新增:图片吸管 ---------------- */
const MAX_W = 720
const MAX_H = 520

const canvasRef = ref(null)
const hasImage = ref(false)
const imgError = ref('')
const imgInfo = ref('')
const imgColor = ref('')
const hoverColor = ref('')
let objectUrl = ''

const imgRgbText = computed(() => {
  if (!imgColor.value) return ''
  const m = imgColor.value.replace('#', '')
  return `rgb(${parseInt(m.slice(0, 2), 16)}, ${parseInt(m.slice(2, 4), 16)}, ${parseInt(m.slice(4, 6), 16)})`
})

const toHex = (r, g, b) =>
  '#' + [r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')

function releaseUrl() {
  if (objectUrl) {
    URL.revokeObjectURL(objectUrl)
    objectUrl = ''
  }
}
onBeforeUnmount(releaseUrl)

function handleImage(file) {
  const raw = file.raw
  if (!raw || !raw.type.startsWith('image/')) {
    imgError.value = '请选择图片文件'
    return
  }
  imgError.value = ''
  releaseUrl()
  objectUrl = URL.createObjectURL(raw)
  const img = new Image()
  img.onload = () => {
    const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight)
    const w = Math.max(1, Math.round(img.naturalWidth * scale))
    const h = Math.max(1, Math.round(img.naturalHeight * scale))
    const cvs = canvasRef.value
    if (!cvs) return
    cvs.width = w
    cvs.height = h
    cvs.style.width = w + 'px'
    const ctx = cvs.getContext('2d', { willReadFrequently: true })
    ctx.clearRect(0, 0, w, h)
    ctx.drawImage(img, 0, 0, w, h)
    hasImage.value = true
    imgInfo.value = `原始尺寸 ${img.naturalWidth}×${img.naturalHeight},画布 ${w}×${h}。`
    imgColor.value = ''
    extractPalette()
  }
  img.onerror = () => {
    imgError.value = '图片加载失败,请换一张试试'
    hasImage.value = false
  }
  img.src = objectUrl
}

// 把鼠标事件坐标换算成画布像素坐标(兼容 CSS 缩放)
function eventToPixel(e) {
  const cvs = canvasRef.value
  if (!cvs) return null
  const rect = cvs.getBoundingClientRect()
  if (!rect.width || !rect.height) return null
  const x = Math.floor((e.clientX - rect.left) * (cvs.width / rect.width))
  const y = Math.floor((e.clientY - rect.top) * (cvs.height / rect.height))
  if (x < 0 || y < 0 || x >= cvs.width || y >= cvs.height) return null
  const d = cvs.getContext('2d', { willReadFrequently: true }).getImageData(x, y, 1, 1).data
  return toHex(d[0], d[1], d[2])
}

function onCanvasClick(e) {
  const hex = eventToPixel(e)
  if (hex) imgColor.value = hex
}
function onCanvasMove(e) {
  hoverColor.value = eventToPixel(e) || ''
}

/* ---------------- 新增:主色调提取 ---------------- */
const paletteSize = ref(6)
const ignoreExtremes = ref(true)
const palette = ref([])
const extracting = ref(false)

// 5bit/通道 直方图量化 + 确定性加权 k-means 聚类
function quantize(pixels, maxColors) {
  if (!pixels.length) return []
  const SHIFT = 3
  const map = new Map()
  for (const p of pixels) {
    const key = ((p[0] >> SHIFT) << 10) | ((p[1] >> SHIFT) << 5) | (p[2] >> SHIFT)
    let e = map.get(key)
    if (!e) { e = { r: 0, g: 0, b: 0, n: 0 }; map.set(key, e) }
    e.r += p[0]; e.g += p[1]; e.b += p[2]; e.n++
  }
  const cells = [...map.values()].map(e => ({ r: e.r / e.n, g: e.g / e.n, b: e.b / e.n, n: e.n }))
  if (cells.length <= maxColors) {
    return cells.sort((a, b) => b.n - a.n).map(c => ({ r: c.r, g: c.g, b: c.b, count: c.n }))
  }

  const dist2 = (a, b) => {
    const dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b
    return dr * dr + dg * dg + db * db
  }
  // k-means++ 风格播种:先取占比最大的格子,之后每次取 n·d² 最大者
  const seeds = [cells.reduce((m, c) => (c.n > m.n ? c : m), cells[0])]
  while (seeds.length < maxColors) {
    let best = null, bestScore = -1
    for (const c of cells) {
      let nearest = Infinity
      for (const s of seeds) {
        const d = dist2(c, s)
        if (d < nearest) nearest = d
      }
      const score = c.n * nearest
      if (score > bestScore) { bestScore = score; best = c }
    }
    if (!best || bestScore <= 0) break
    seeds.push({ r: best.r, g: best.g, b: best.b })
  }

  let centers = seeds.map(s => ({ r: s.r, g: s.g, b: s.b, n: 0 }))
  for (let iter = 0; iter < 12; iter++) {
    const acc = centers.map(() => ({ r: 0, g: 0, b: 0, n: 0 }))
    for (const c of cells) {
      let bi = 0, bd = Infinity
      for (let i = 0; i < centers.length; i++) {
        const d = dist2(c, centers[i])
        if (d < bd) { bd = d; bi = i }
      }
      acc[bi].r += c.r * c.n
      acc[bi].g += c.g * c.n
      acc[bi].b += c.b * c.n
      acc[bi].n += c.n
    }
    let moved = 0
    for (let i = 0; i < centers.length; i++) {
      if (!acc[i].n) continue
      const nr = acc[i].r / acc[i].n, ng = acc[i].g / acc[i].n, nb = acc[i].b / acc[i].n
      moved += Math.abs(nr - centers[i].r) + Math.abs(ng - centers[i].g) + Math.abs(nb - centers[i].b)
      centers[i] = { r: nr, g: ng, b: nb, n: acc[i].n }
    }
    if (moved < 0.5) break
  }
  return centers.filter(c => c.n).map(c => ({ r: c.r, g: c.g, b: c.b, count: c.n })).sort((a, b) => b.count - a.count)
}

function extractPalette() {
  const cvs = canvasRef.value
  if (!cvs || !hasImage.value) return
  extracting.value = true
  try {
    const ctx = cvs.getContext('2d', { willReadFrequently: true })
    const { data, width, height } = ctx.getImageData(0, 0, cvs.width, cvs.height)
    // 大图按步长抽样,控制在 4 万像素以内
    const total = width * height
    const step = Math.max(1, Math.floor(Math.sqrt(total / 40000)))
    const pixels = []
    for (let y = 0; y < height; y += step) {
      for (let x = 0; x < width; x += step) {
        const i = (y * width + x) * 4
        if (data[i + 3] < 128) continue // 跳过透明像素
        const r = data[i], g = data[i + 1], b = data[i + 2]
        if (ignoreExtremes.value) {
          const mx = Math.max(r, g, b), mn = Math.min(r, g, b)
          if (mx > 245 && mn > 245) continue // 近白
          if (mx < 12) continue // 近黑
        }
        pixels.push([r, g, b])
      }
    }
    if (!pixels.length) {
      palette.value = []
      imgError.value = '没有可用于统计的像素(可尝试关闭「忽略近白 / 近黑像素」)'
      return
    }
    imgError.value = ''
    const sum = pixels.length
    palette.value = quantize(pixels, paletteSize.value).map(c => ({
      hex: toHex(c.r, c.g, c.b),
      percent: Math.round((c.count / sum) * 1000) / 10
    }))
  } catch (e) {
    imgError.value = '主色提取失败:' + e.message
    palette.value = []
  } finally {
    extracting.value = false
  }
}

/* ---------------- 新增:导出调色板 ---------------- */
const exportFormat = ref('css')
const exportText = computed(() => {
  if (!palette.value.length) return ''
  const rows = palette.value
  if (exportFormat.value === 'list') {
    return rows.map((p, i) => `${String(i + 1).padStart(2, '0')}. ${p.hex}  ${p.percent}%`).join('\n')
  }
  if (exportFormat.value === 'scss') {
    return rows.map((p, i) => `$palette-${i + 1}: ${p.hex}; // 占比 ${p.percent}%`).join('\n')
  }
  return ':root {\n' +
    rows.map((p, i) => `  --palette-${i + 1}: ${p.hex}; /* 占比 ${p.percent}% */`).join('\n') +
    '\n}'
})

2.3 效果截图

屏幕取色器 效果截图

工具访问地址:https://www.i91tools.com/tools/color-picker