先看下效果
像素图的数据构成
RGBA
像素图的每一个点,由RGBA
4个数值组成:
R
redG
greenB
blueA
alpha 透明度通道
其中RGB
三个颜色,各对应显示器上的一个颜色发光元件;通过这三个点的亮度调整,让这个像素呈现出不同的颜色。
每个颜色通过值的大小来控制发光元件的亮度,这个值在0~255
的范围内,也就是说每一个颜色层有256种灰度阶梯变化,所以RGB
三种颜色,可产生256^3
种颜色,也就是常说的16M真彩
。
另外的Alpha
通道,用来控制透明度,在多层图片合成的时候才会用到。
Canvas绘图
一个简单的例子:
const canvas = document.createElement('canvas')
canvas.width = 2
canvas.height = 2
// CanvasRenderingContext2D
const ctx = canvas.getContext('2d')
ctx.fillStyle = `rgba(10,20,30,0.5)`
ctx.fillRect(0, 1, 1, 1)
复制代码
这个例子中,创建了一个2x2
的画布,并通过ctx
在(0,1)
点(也就是左下角)绘制了一个颜色为rgba(10,20,30,0.5)
的矩形;由于改矩形只有1x1
的尺寸,实际上就是绘制了1个像素。
获取结构化的图片数据
通过CanvasRenderingContext2D
的实例(上文中的ctx
),我们可以获取结构化的图片数据ImageData
:
const canvas = document.createElement('canvas')
canvas.width = 2
canvas.height = 2
// CanvasRenderingContext2D
const ctx = canvas.getContext('2d')
ctx.fillStyle = `rgba(10,20,30,0.5)`
ctx.fillRect(0, 1, 1, 1)
const imageData = ctx.getImageData(0, 0, 2, 2)
复制代码
此时imageData
的主要结构为:
{
"width": 2,
"height": 2,
"data": [
0, 0, 0, 0, // [0, 0]
0, 0, 0, 0, // [1, 0]
10, 20, 30, 128, // [0, 1]
0, 0, 0, 0 // [1, 1]
]
}
复制代码
这里创建的画布是2×2的尺寸,共4个像素点;每个像素点4个值元素,从左到右、从上到下依次排列。按这个顺序,刚才绘制的(0,1)
点,应为数组中的第三组数据;其他部分没有绘制,所以所有的值都保持为初始值0
。
注意这里的A
值仍表示为0~255
,而css
的rgba
中的a
是0~1
,绘制完成后,数据会自动转换。
像素点遍历
由于data
是个扁平数组,用起来非常麻烦,我们写几个traverse
方法,基于像素点进行遍历,后续用起来会比较方便。
定义类型
/** RGBA颜色 */
export type TColor = [number, number, number, number]
/**
* 像素信息类型
*/
export type TPixelInfo = {
/** 当前像素点色值 [R,G,B,A] */
color: TColor
/** 全局索引位置 */
index: number
/** 横坐标 */
x: number
/** 纵坐标 */
y: number
/** 画布宽度 */
width: number
/** 画布高度 */
height: number
}
复制代码
坐标换算
/**
* 通过坐标创建像素信息对象
* @param image
* @param x
* @param y
* @returns
*/
export const createPixelInfo = (image: ImageData, x: number, y: number) => {
const { width, height, data } = image
const pixelIdx = y * width + x
const index = pixelIdx * 4
const color: TColor = [
data[index + 0], data[index + 1],
data[index + 2], data[index + 3]
]
const info: TPixelInfo = {
width,
height,
index,
color,
x,
y
}
return info
}
复制代码
线性遍历
逐个像素点的按序访问,实用不多,先热个身:
/**
* 线性遍历
* @param imageData 图片数据
* @param cb 回调
*/
export const traverseImageData = (imageData: ImageData, cb: (color: TPixelInfo) => void) => {
const { data, width } = imageData
for(let i = 0; i < data.length; i += 4) {
const idx = i / 4
cb(createPixelInfo(imageData, idx % width, idx / width >> 0))
}
}
复制代码
非线性遍历和采样
大部分图片处理滤镜都涉及采样,比如模糊滤镜、放大缩小、平滑处理,等等。
/**
* 非线性采样
* @param imageData 图片数据
* @param cb 回调
* @param step 步长
* @param samplingOffset 采样像素偏移
*/
export const traverseSampling = (
imageData: ImageData,
cb: (color: TPixelInfo) => void,
step: number = 1,
samplingOffset: number = 0
) => {
if(step < 1) {
step = 1
}
const { data, width, height } = imageData
const rows = height / step >> 0
const columns = width / step >> 0
for(let Y = 0; Y < rows; Y++) {
for(let X = 0; X < columns; X++) {
const info = createPixelInfo(imageData, X * step, Y * step)
/** 采样偏移量 */
const sampOffset = step * samplingOffset >> 0
/** 采样像素位置索引 */
const sampPixelIdx = width * (info.y + sampOffset) + info.x + sampOffset
/** 采样像素数组索引 */
const sampIdx = sampPixelIdx * 4
/** 采样点颜色重新赋值 */
info.color = [
data[sampIdx],
data[sampIdx + 1],
data[sampIdx + 2],
data[sampIdx + 3]
]
cb(info)
}
}
}
复制代码
非线性遍历是基于坐标系访问,而不是通过数组顺序访问,比较符合人类的习惯。
采样的原理,是通过设置步长来降低采样精度
,按一定间隔取采样基点
并输出给回调函数。本文中,我们不做插值
之类的提高精度的算法。
采样过程中,在每个采样基点
的右下方向、step
长宽范围,是一块被略过的区域,这个区域被称为采样区
。
最后一个参数samplingOffset
采样像素偏移,是基于采样基点
向采样区对角线方向延伸,或者说采样的色值可以偏离采样基点
,按需在采样区取值。
当samplingOffset
为0
时,采样color
取的是采样基点
的色值,也就是采样区
的左上角的点;如果需要取采样区
中心点的色值,那么将samplingOffset
设置为0.5
即可。
坐标区块遍历
遍历图片中某个矩形区域的像素点:
/**
* 遍历矩形区域
* @param imageData 结构化图片数据
* @param rect 矩形区域 `[x, y, width, height]`
* @param cb 回调
*/
export const traverseArea = (
imageData: ImageData,
rect: [number, number, number, number],
cb: (color: TPixelInfo, data: ImageData) => void
) => {
const { width, height } = imageData
const [X, Y, W, H] = rect
for(let y = Y; y < Math.min(Y + H, height); y++) {
for(let x = X; x < Math.min(X + W, width); x++) {
cb(createPixelInfo(imageData, x, y), imageData)
}
}
}
复制代码
像素点修改
直接改ImageData.data
那个Uint8ClampedArray
类型数组即可:
/**
* 基于数组本身索引修改
* @param color
* @param index
* @param array
*/
export const assignColor = (
color: [number, number, number, number],
index: number,
array: Uint8ClampedArray
) => {
const [r, g, b, a] = color
array[index + 0] = r
array[index + 1] = g
array[index + 2] = b
array[index + 3] = a
}
/**
* 基于坐标系修改
* @param color
* @param x
* @param y
* @param image
* @returns
*/
export const setColor = (
color: [number, number, number, number],
x: number,
y: number,
image: ImageData
) => {
const { width, height, data } = image
if (y < 0 || y >= height || x < 0 || x >= width) {
return
}
const pixelIdx = y * width + x
const index = pixelIdx * 4
assignColor(color, index, data)
}
复制代码
Mosaic滤镜
马赛克滤镜,实际就是降低采样度,然后将采样点扩张到整个采样区域,实现图片的遮挡处理。
根据上面已经写好的工具能提供的能力,以及滤镜本身的特质,做一下需求分析:
- 可设置马赛克瓦片大小
- 可设置采样偏移
- 管道模式
- 对数据原地更改
瓦片切割和遍历
/**
* Mosaic滤镜
* @param image 图片数据
* @param size 瓦片尺寸
* @param offset 采样偏移
* @returns
*/
export const filterMosaic = (image: ImageData, size: number, offset: number = 0.5) => {
// 如果瓦片尺寸小于2,不必处理
if(size < 2) {
return image
}
// 按瓦片大小遍历和采样
traverseSampling(image, ({ x, y, color }) => {
// 基于采样点,对瓦片区域的像素点进行遍历
traverseArea(image, [x, y, size, size], ({ x, y }, image) => {
// 将瓦片区域所有像素点设置为采样点颜色
setColor(color, x, y, image)
})
}, size, offset)
return image
}
复制代码
滤镜工厂
把size
和offset
参数curry
到高阶函数中:
/**
* Mosaic滤镜工厂
* @param size 瓦片大小
* @param offset 采样偏移
* @returns
*/
export const createMosaicFilter = (size: number, offset: number = 0.5) => (image: ImageData) => {
return filterMosaic(image, size, offset)
}
复制代码
页面实现
// some imports
export default (props) => {
// 输出mosaic滤镜的画布
const canvas = createRef()
// 瓦片尺寸
const [size, setSize] = useState(8)
// 采样偏移
const [offset, setOffset] = useState(0.5)
// 原始图片对象(DOM)
const [img, setImg] = useState(null)
// 功过putImageData输出滤镜处理后的数据
const drawMosaic = (imageData, canvas) => {
imageData = createMosaicFilter(size, offset)(imageData)
canvas.width = imageData.width
canvas.height = imageData.height
const ctx = canvas.getContext('2d')
ctx.putImageData(imageData, 0, 0)
}
// some event handles
useEffect(() => {
if(!img || !canvas.current) {
return
}
const data = getImageData(img)
drawMosaic(data, canvas.current)
}, [img, size, offset])
return (
<div className="mosaic-container">
{/* some jsx */}
<div className="mosaic-row">
<img className="mosaic-origin" src={`./images/logo.jpg`} onLoad={handleImageLoad} />
<canvas className="mosaic-canvas" ref={canvas} />
</div>
</div>
)
}
复制代码
通过滑杆可调整瓦片大小和采样偏移。
字符画
一开始说到过,在RGBA
颜色结构中,每个颜色都是用0~255
的数组表示,也就是有256层梯度;如果单拿一层出来,实际上只能形成一个灰阶图,没有颜色。
我们经常看到的字符画,实际是用一个字符来表示一个像素的灰阶,整体上实现一个图片的观感,和以莫奈为代表的印象派绘画有些类似:离越远观感越好。
字符的灰阶值
比如要测定大写字母A
的灰阶值,创建一个120x120
尺寸的画布,然后在上面画一个黑色字母A
。此时字母A
覆盖的范围,不透明度Alpha
值是非0
的。然后统计所有Alpha非零
的像素数量,再除以像素总数,就可以得到该字母的灰阶值。对一组字符取灰阶值,然后根据灰阶值去重,就可以得到一张灰阶字符表,放在一旁备用:
/**
* 获取数据灰阶
* @param data
* @returns
*/
const getRate = (data: Uint8ClampedArray) => {
let c = 0
for (let i = 0; i < data.length; i += 4) {
const val = data[i + 3]
if (val > 0) {
c += 1
}
}
const C = data.length / 4
const rate = c / C
return rate
}
/** 缓存 */
const CACHE: {
FULL_CHAR_MAP?: { letter: string, value: number } []
} = {}
/**
* 灰阶字符表
* @param fontFamily 字体
* @param size 字号
* @returns
*/
export const getFullCharMap = (fontFamily = 'monospace', size = 240) => {
if(!CACHE.FULL_CHAR_MAP) {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
const rateMap: { letter: string, value: number }[] = []
for (let i = 32; i < 128; i++) {
const letter = String.fromCharCode(i)
const { data } = drawLetterData(ctx, letter, size, fontFamily)
const rate = getRate(data)
const val = Math.round(rate * 256)
const item = rateMap.find(({ value }) => value === val)
if (!item) {
rateMap.push({ letter, value: val })
}
}
CACHE.FULL_CHAR_MAP = rateMap
// .filter(({ value }) => value > 0)
.sort((b, a) => a.value - b.value)
}
return CACHE.FULL_CHAR_MAP
}
复制代码
采样
一个字符可以看做一个瓦片,以字符作为一个像素点,这和mosaic
类似,需要降低采样精度。
在采样点上,不需要取所有的颜色层。本文取的绿色层作为灰阶值依据,然后从灰阶字符标里查找对应的字符。
类型定义
export type TMosacPoint = [number, number, TColor]
export type TMosacData = {
width: number, height: number,
size: number,
points: TMosacPoint[],
columns: number,
rows: number,
}
复制代码
收集采样点
这里实际上是定义了所有的瓦片,采集了瓦片基准颜色,返回了一些信息备用。
/**
* 收集采样点
* @param image
* @param size
* @param offset
* @returns
*/
export const getMosaicData = (image: ImageData, size: number, offset: number = 0.5): TMosacData => {
const { width, height } = image
const rows = height / size >> 0
const columns = width / size >> 0
const points: TMosacPoint[] = []
traverseSampling(image, ({ x, y, color }) => {
points.push([x, y, color])
}, size, offset)
return { points, width, height, size, columns, rows }
}
复制代码
单个采样转字符
/**
* 采样转字符(Green层)
* @param point
* @param charMap
* @returns
*/
export const samp2char_g = (
point: TMosacPoint,
charMap: { letter: string, value: number }[]
) => {
// 取绿色
const [, g] = point[2].map(v => v / 256)
// 换算绿色灰阶到字符灰阶的索引
const charIndex = g * charMap.length >> 0
const char = charMap[charIndex]
return char.letter
}
复制代码
切割采样集合为多行字符串
export const mosaic2lines = (
mosaicData: TMosacData,
charMap: { letter: string, value: number }[] = getFullCharMap()
): string[] => {
const chars = mosaicData.points.map(point => samp2char_g(point, charMap))
const lines: string[] = []
while(chars.length > 0) {
lines.push(chars.splice(0, mosaicData.columns).join(''))
}
console.log(lines.join('\n'))
return lines
}
复制代码
控制台能看到点意思了
行样式调整
整体偏高,在显示时,需要降低行距,拉开字符间距。
.impress-line {
margin: 0;
line-height: 0.7em;
font-family: monospace;
font-size: 12px;
color: #000;
letter-spacing: 1px;
}
复制代码
注意一定要用等宽字体!
页面实现
// some imports
export default (props) => {
// 字符画过大时,通过transform:scale调整字符画整体尺寸。
// 因为字号不能小于11px。
const [scale, setScale] = useState(1)
// 瓦片大小
const [size, setSize] = useState(6)
// 图片结构化数据
// 因为不是原地转换,所以直接缓存
const [imageData, setImageData] = useState(null)
// 字符画的行数据
const [lines, setLines] = useState([])
const drawImpress = (imageData) => {
// 收集采样点
const mosaicData = getMosaicData(imageData, size)
// 生成字符画的行
const lines = mosaic2lines(mosaicData)
// 推算字符画宽度
const mayWidth = mosaicData.columns * 8
// 根据字符画宽度和图片宽度,计算scale值
if(mayWidth > imageData.width) {
const scale = imageData.width / mayWidth
setScale(scale)
} else {
setScale(imageData.width / mayWidth)
}
// 配置字符画数据
setLines(lines)
}
// some event handles
useEffect(() => {
if(!imageData) {
return
}
drawImpress(imageData)
}, [imageData, size])
return (
<div className="impress-container">
{* some jsx *}
<div className="impress-row">
<img className="impress-origin" src={`./images/logo.jpg`}
onLoad={handleImageLoad} />
<div className="impress-lines-container"
style={{ transform: `scale(${scale})` }}
>{
lines.map((line, idx) =>
<pre className="impress-line" key={idx}>{line}</pre>
)
}</div>
</div>
</div>
)
}
复制代码
输出
这印象还挺萌的。
视频
视频的实现细节,关键是是用一个canvas
桥接:先把视频画到cavans上,然后从canvas拿数据做成字符画展示。
另外由于视频是动态的,所以需要不断对桥接canvas
进行更新绘制。
帧循环工厂
export const animationFactory = <T>(cb: (opts?: T) => void, opts?: T) => {
let stop = false, handle = 0
const loop = () => {
cancelAnimationFrame(handle)
if(stop) {
return
}
cb(opts)
handle = requestAnimationFrame(loop)
}
return {
start() {
stop = false
requestAnimationFrame(loop)
},
stop() {
stop = true
cancelAnimationFrame(handle)
}
}
}
复制代码
桥接Canvas
确定画布尺寸
当视频的loadedMetaData
事件触发时,我们就可以拿到视频的尺寸了:
// 动画帧工厂
const frameDraw = (ctx, video, setImageData) => () => {
ctx.drawImage(video, 0, 0)
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
setImageData(imageData)
}
let eng = null
const handleLoadedMetaData = (e) => {
const { target } = e
const { current: cvs } = canvas
cvs.width = target.videoWidth
cvs.height = target.videoHeight
const ctx = cvs.getContext('2d')
if(!eng) {
// 创建动画帧
const frame = frameDraw(ctx, video.current, setImageData)
// 创建动画引擎
eng = animationFactory(frame)
// 启动动画引擎,开始对桥接canvas更新绘制
eng.start()
}
}
复制代码
画布到字符画
字符画部分没有什么改动
const drawImpress = (imageData) => {
// 收集
const mosaicData = getMosaicData(imageData, size)
// 合成字符
const lines = mosaic2lines(mosaicData)
// 监测宽度
const mayWidth = mosaicData.columns * 8
// 适配
if(mayWidth > imageData.width) {
const scale = imageData.width / mayWidth
setScale(scale)
} else {
setScale(imageData.width / mayWidth)
}
// 渲染
setLines(lines)
}
复制代码
页面实现
// some imports
const frameDraw = (ctx, video, setImageData) => () => {
ctx.drawImage(video, 0, 0)
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
setImageData(imageData)
}
export default (props) => {
// 视频播放器
const video = createRef()
// 桥接canvas
const canvas = createRef()
// 字符画整体尺寸适配
const [scale, setScale] = useState(1)
// 瓦片尺寸
const [size, setSize] = useState(8)
// 图片数据
const [imageData, setImageData] = useState(null)
// 字符画数据
const [lines, setLines] = useState([])
// 循环帧引擎
let eng = null
const handleLoadedMetaData = (e) => {
// ...
}
const drawImpress = (imageData) => {
// ...
}
// some event handles
useEffect(() => {
if(!imageData) {
return
}
drawImpress(imageData)
}, [imageData, size])
useEffect(() => () => {
if(eng) {
eng.stop()
}
}, [])
return (
<div className="video-container">
{* some jsx *}
<div className="video-row">
<video
ref={video}
controls
autoPlay
loop
className="video-origin"
src="./images/cat.mp4"
onLoadedMetadata={handleLoadedMetaData}
/>
<canvas ref={canvas}
style={{ backgroundColor: '#eee', display: 'none' }} />
<div className="video-lines-container"
style={{ transform: `scale(${scale})` }}
>{
lines.map((line, idx) =>
<pre className="video-line" key={idx}>{line}</pre>
)
}</div>
</div>
</div>
)
}
复制代码
以上。