狮之印象:基于Canvas的图片像素处理

先看下效果

image.png

image.png

image.png

完整项目:github.com/imnull/txt-…

像素图的数据构成

RGBA

像素图的每一个点,由RGBA4个数值组成:

  • R red
  • G green
  • B blue
  • A 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,而cssrgba中的a0~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采样像素偏移,是基于采样基点向采样区对角线方向延伸,或者说采样的色值可以偏离采样基点,按需在采样区取值。

samplingOffset0时,采样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
}
复制代码

滤镜工厂

sizeoffset参数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>
    )
}
复制代码

Mosaic滤镜

通过滑杆可调整瓦片大小和采样偏移。

字符画

一开始说到过,在RGBA颜色结构中,每个颜色都是用0~255的数组表示,也就是有256层梯度;如果单拿一层出来,实际上只能形成一个灰阶图,没有颜色。

我们经常看到的字符画,实际是用一个字符来表示一个像素的灰阶,整体上实现一个图片的观感,和以莫奈为代表的印象派绘画有些类似:离越远观感越好。

image.png

image.png

字符的灰阶值

比如要测定大写字母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
}
复制代码

控制台能看到点意思了

image.png

行样式调整

整体偏高,在显示时,需要降低行距,拉开字符间距。

.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>
    )
}
复制代码

输出

image.png

image.png

这印象还挺萌的。

视频

视频的实现细节,关键是是用一个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>
    )
}
复制代码

2021-08-05 10.31.00.gif

以上。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享