前言
本文主要介绍如何用 四向扫描算法 来获取 psd
图层的轮廓信息,也算是补充了上一篇文章使用psd.js将psd路径转成svg格式里面获取轮廓功能的不足之处,上一篇只能获取形状图层的轮廓,这难免有时候不太方便,所有本文通过算法解析所有像素,来读取简单的图层外轮廓
获取图层像素数据
在上一篇已经讲过了要如何导入 psd
文件并且获取具体的图层数据,所有在这里就不再多说,在我们拿到具体的某一个图层的数据之后,如果当前不是一个形状图层,那么我们就可以通过其他方式来获取轮廓,首先需要获取一下这个图层的像素数据
我们通过 pixelData
来获取图层的像素数据。在 PSD (Photoshop Document)
文件中, pixelData
通常指的是图像的像素数据,它存储了每个像素的颜色和透明度信息 。在 Photoshop
中,每个图层都包含自己的 pixelData
,这些数据共同构成了最终的图像效果。
在实际的图层里面,我们可以通过 node.layer.image.pixelData
来获取当前这个图层的像素数据
这个数据在经过 psd.js
的处理之后,每个像素的各通道值在 pixelData
中是线性排列的,索引计算方式为:(y \* width + x) \* channels + channelIndex
。
新建图片,转化图层像素数据
通过 canvas
的 createImageData 方法,我们可以创建一个空的 ImageData
对象,然后经过处理,将图层的数据都渲染到这张图片上面
针对 ImageData
对象来说,最关键的就是 ImageData.data
,一个 Uint8ClampedArray
格式的存储对象。
然后我们通过方法转化 node.layer.image.pixelData
为 ImageData.data
数据
js
const pixelData = node.layer.image.pixelData
// 将pixelData复制到imgData
// 注意:PSD格式可能是分通道存储的,需要转换为RGBA格式
let imgDataIndex = 0
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// 设置RGBA值
const pixelIndex = (y * width + x) * channels
// 设置RGB通道
for (let c = 0; c < Math.min(3, channels); c++) {
imgData.data[imgDataIndex + c] = pixelData[pixelIndex + c] || 0
}
// 设置Alpha通道 (如果没有Alpha通道,设置为255,完全不透明)
imgData.data[imgDataIndex + 3] =
channels > 3 ? pixelData[pixelIndex + 3] : 255
imgDataIndex += 4
}
}
console.log(imgData)

通过四向扫描算法获取图片的轮廓数据
首先我们先来了解一下 ImageData.data
也就是 Uint8ClampedArray
类型,这个类型有三个特点:
- 数据排列方式:按照
RGBA
四个值一组排列,每个像素占用连续的 4 个元素- 索引 0, 4, 8... 存储红色通道 (R) 值
- 索引 1, 5, 9... 存储绿色通道 (G) 值
- 索引 2, 6, 10... 存储蓝色通道 (B) 值
- 索引 3, 7, 11... 存储透明度通道 (A) 值
- 值范围:每个通道的值范围是 0-255
- 对于 Alpha 通道,0 表示完全透明,255 表示完全不透明
- 数据类型:
Uint8ClampedArray
确保所有值都被限制在 0-255 范围内
简单理解这是一个非常长的数组,里面存储了每一个点位的颜色,透明度 等信息,然后我们就可以通过图片的宽度或者长度来找到 (x,y)
这个点位在数组里面的哪个位置,然后我们只是需要判断当前的点位是否为透明,所有就有了以下函数
js
// 判断像素是否不透明
const isOpaque = (x: number, y: number): boolean => {
if (x < 0 || x >= width || y < 0 || y >= height) return false
const idx = (y * width + x) * 4
return data[idx + 3] > 0 // Alpha通道大于0表示不透明
}
然后四向扫面,就是通过上下左右四个方向逐行扫面,碰到不透明的元素就退出,记录下点位,当然这种方法就只能判断一些简单图形,如果需要判断复杂图形的轮廓,那就需要修改这个算法
js
// 1. 从上到下扫描(获取上边界)
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
if (isOpaque(x, y)) {
contourPoints.push({ x, y })
break // 找到第一个不透明像素后停止向下扫描
}
}
}
// 2. 从右到左扫描(获取右边界)
for (let y = 0; y < height; y++) {
for (let x = width - 1; x >= 0; x--) {
if (isOpaque(x, y)) {
// 避免重复添加已有的点
if (!contourPoints.some((p) => p.x === x && p.y === y)) {
contourPoints.push({ x, y })
}
break // 找到第一个不透明像素后停止向左扫描
}
}
}
// 3. 从下到上扫描(获取下边界)
for (let x = width - 1; x >= 0; x--) {
for (let y = height - 1; y >= 0; y--) {
if (isOpaque(x, y)) {
// 避免重复添加已有的点
if (!contourPoints.some((p) => p.x === x && p.y === y)) {
contourPoints.push({ x, y })
}
break // 找到第一个不透明像素后停止向上扫描
}
}
}
// 4. 从左到右扫描(获取左边界)
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
if (isOpaque(x, y)) {
// 避免重复添加已有的点
if (!contourPoints.some((p) => p.x === x && p.y === y)) {
contourPoints.push({ x, y })
}
break // 找到第一个不透明像素后停止向右扫描
}
}
}
console.log('四向扫描获取的轮廓点数量:', contourPoints.length)
到这里为止我们就获得了所有不透明的外轮廓点位,但是我们还可以进一步的优化这些点位数据,通过 Ramer-Douglas-Peucker
算法 对所有的点位进行简化:
js
// 对轮廓点进行排序,确保它们按照顺时针或逆时针顺序排列
const sortContourPoints = (points: ContourPoint[]): ContourPoint[] => {
if (points.length <= 2) return points
// 计算轮廓的中心点
let sumX = 0,
sumY = 0
for (const point of points) {
sumX += point.x
sumY += point.y
}
const centerX = sumX / points.length
const centerY = sumY / points.length
// 按照相对于中心点的角度排序
return [...points].sort((a, b) => {
const angleA = Math.atan2(a.y - centerY, a.x - centerX)
const angleB = Math.atan2(b.y - centerY, b.x - centerX)
return angleA - angleB
})
}
// 简化轮廓点,移除过于密集的点
const simplifyContour = (
points: ContourPoint[],
tolerance: number,
): ContourPoint[] => {
if (points.length <= 2) return points
// 先对点进行排序
const orderedPoints = sortContourPoints(points)
// 使用Ramer-Douglas-Peucker算法简化轮廓
const result: ContourPoint[] = []
// 计算点到线段的距离
const pointToLineDistance = (
p: ContourPoint,
start: ContourPoint,
end: ContourPoint,
): number => {
const { x, y } = p
const { x: x1, y: y1 } = start
const { x: x2, y: y2 } = end
// 如果起点和终点重合,则计算点到点的距离
if (x1 === x2 && y1 === y2) {
return Math.sqrt((x - x1) ** 2 + (y - y1) ** 2)
}
// 计算点到线段的距离
const A = x - x1
const B = y - y1
const C = x2 - x1
const D = y2 - y1
const dot = A * C + B * D
const lenSq = C * C + D * D
let param = -1
if (lenSq !== 0) param = dot / lenSq
let xx, yy
if (param < 0) {
xx = x1
yy = y1
} else if (param > 1) {
xx = x2
yy = y2
} else {
xx = x1 + param * C
yy = y1 + param * D
}
const dx = x - xx
const dy = y - yy
return Math.sqrt(dx * dx + dy * dy)
}
// 递归简化
const simplifyRDP = (start: number, end: number) => {
if (end - start <= 1) return
let maxDistance = 0
let maxIndex = start
for (let i = start + 1; i < end; i++) {
const distance = pointToLineDistance(
orderedPoints[i],
orderedPoints[start],
orderedPoints[end],
)
if (distance > maxDistance) {
maxDistance = distance
maxIndex = i
}
}
if (maxDistance > tolerance) {
simplifyRDP(start, maxIndex)
result.push(orderedPoints[maxIndex])
simplifyRDP(maxIndex, end)
}
}
// 添加第一个点
result.push(orderedPoints[0])
// 简化中间点
simplifyRDP(0, orderedPoints.length - 1)
// 添加最后一个点
result.push(orderedPoints[orderedPoints.length - 1])
// 确保轮廓是闭合的
if (
result.length > 2 &&
(result[0].x !== result[result.length - 1].x ||
result[0].y !== result[result.length - 1].y)
) {
result.push({ ...result[0] })
}
return result
}
对比一下简化前后的点位数据

然后接下去的流程就比较简单了,已经获取到了每一个点位的坐标,只需要通过位置来生成对应的 path
数据,就能够得到 svg
了。
js
/ 将轮廓点转换为SVG路径
const contourToPath = (points: ContourPoint[]): string => {
if (points.length < 3) return ''
// 使用简单的折线路径
let pathData = `M ${points[0].x},${points[0].y}`
for (let i = 1; i < points.length; i++) {
pathData += ` L ${points[i].x},${points[i].y}`
}
// 闭合路径
if (
points[0].x !== points[points.length - 1].x ||
points[0].y !== points[points.length - 1].y
) {
pathData += ' Z'
}
return `<path d="${pathData}" fill="none" stroke="black" stroke-width="1" />`
}
总结
本文通过像素点分析的方式解析图层的轮廓,但是使用的扫描方式相对简单,只能用来解析一些简单的图形,如果需要扫描复杂图形可以考虑上一篇文章中的创建形状路径或者重写扫面算法。