• 博文
  • 归档
  • AI入门之环境安装

    2021-12-14

    次访问

    学习AI,首先我们需要安装相关开发环境。本文主要安装Anaconda、Pytorch、Jupyter NoteBook。

    Anaconda

    Anaconda是一个开源的Python和R语言的发行版本,用于计算科学(数据科学、机器学习、大数据处理和预测分析)。
    Anaconda是一个包管理器,也是一个环境管理器,个人版免费。

    安装

    参考官网,选择不同的环境安装。

    使用推荐的仅为我安装,否则开启Anaconda终端需要管理员权限。

    anaconda_install_user

    安装过程中,勾选将Anaconda作为默认的Python环境:
    anaconda_install

    官方不推荐把Anaconda3加到环境变量中。
    如果加到环境变量中,使用起来更方便,任意终端都可以输入conda命令,但是可能会造成一些问题需要卸载重装Anaconda。
    推荐不加环境变量,从开始目录选择Anaconda终端打开:
    conda_start

    验证是否安装成功,在终端中输入conda –version后回车
    conda_version

    如果把Anaconda3加到环境变量中,运行python时可能会看见下面的警告:

    1
    2
    3
    4
    Warning:
    This Python interpreter is in a conda environment, but the environment has
    not been activated. Libraries may fail to load. To activate this environment
    please see https://conda.io/activation.

    Windows上,要解决上面的警告,需要运行c:\Anaconda3\Scripts\activate base。
    这里的c:\Anaconda3\Scripts\activate代表Anaconda是安装在c盘根目录下的,需要换成你自己的安装路径。
    安装路径最好是全英文且无空格的,可以避免很多未知的坑。

    比如我是装在d盘下:

    conda_path

    源

    修改源到国内镜像:

    1
    2
    3
    4
    5
    6
    # 配置源
    conda config --add channels http://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free
    conda config --add channels http://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main

    # 查看当前源
    conda config --show channels

    常用命令

    1
    2
    3
    4
    conda create -n py38 python=3.8 #创建虚拟环境
    conda info -e #查看环境列表
    conda list #查看当前已安装的包
    conda install pytorch #安装开源软件包,比如安装pytorch

    Pytorch

    PyTorch 是一个 Python 包,它提供了两个高级特性:

    • 具有强大的GPU加速的张量计算(如NumPy)
    • 深度神经网络
      需要时可以用别的Python包扩展PyTorch,比如NumPy、SciPy、Cython。

    安装
    先配置镜像,解决下载速度慢,有的包不存在的问题:

    1
    2
    3
    4
    5
    conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
    conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/msys2/
    conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/
    conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/
    conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/

    安装:

    1
    conda install pytorch torchvision torchaudio cpuonly

    如果有安装失败的,就单独再安装一次。

    验证:

    1
    2
    3
    python
    import torch
    torch.Tensor([1])

    torch

    Jupyter NoteBook

    Jupyter NoteBook 把基于控制台的方法扩展的一个交互式计算的新方向,提供一个基于web的应用,捕捉整个计算过程:开发、记录和执行代码,以及交流结果。

    web应用:一个基于浏览器的工具,用于交互式编写文档,结合了文本、数学、计算和它们的富媒体输出。

    记事本文档: web应用程序中所有可见内容的表示,包括计算的输入和输出、解释性文本、数学、图像和对象的富媒体表示。

    安装

    1
    conda install jupyterlab

    使用

    在终端执行下面的命令,你将看到notebook打开了浏览器:

    1
    jupyter notebook

    开启服务后,终端会打印一些信息,比如web应用的地址(默认是http://localhost:8888):

    jupyter_notebook
    jupyter_python

    ...more
  • 前端文件系统

    2021-11-16

    次访问

    前言

    前端文件系统api(The File System Access API)让web应用可以读写用户本地的文件或文件夹。使我们可以开发出能够和用户本地文件交互的web应用,比如IDE,图片和视频编辑器,文字编辑器等等。

    在用户开启web应用权限后,这些api可以直接在用户本地读写文件或文件夹,打开一个目录并显示里面的内容,在用户本地创建或删除文件夹和文件。

    打开

    比如页面中有个打开按钮:

    1
    <div id="open">打开</div>

    点击后,调用showOpenFilePicker就可以弹出文件选择窗,我们选择文件,再调用getFile即可获取文件的file数据,这个file数据和获取的file数据一样。

    1
    2
    3
    4
    5
    6
    const openElm = document.getElementById('open')
    let fileHandle
    openElm.addEventListener('click', async () => {
    [fileHandle] = await window.showOpenFilePicker()
    const file = await fileHandle.getFile()
    })

    上面的例子中,我们选择了一个文件,showOpenFilePicker返回了FileSystemFileHandle类型的数组:

    fileHandle

    这里的fileHandle将会很有用,后面的保存等操作都需要它。

    区分 file pickers

    有时应用会有多个不同的picker,比如富文本编辑器可以打开文本,也可以打开图片,默认情况下,每个file picker会记住上次的路径,我们可以通过id来区分不同的file picker,让它们记住不同的最近一次打开的路径。

    1
    2
    3
    4
    5
    6
    7
    const fileHandle1 = await window.showSaveFilePicker({
    id: 'openText',
    })

    const fileHandle2 = await window.showSaveFilePicker({
    id: 'importImage',
    })

    不过这个功能,我在windows上Chrome版本 96.0.4664.45(正式版本) (64 位)上试验失败了,file picker没有记住上次打开的路径。

    保存

    保存会重写原文件。

    页面中,我们放一个打开和保存按钮,还有一个文本框:

    1
    2
    3
    <div id="open">打开</div>
    <div id="save">保存</div>
    <textarea id="textArea"></textarea>

    点击打开按钮,我们选择文件,比如test.txt,并把文本内容显示到textArea:

    1
    2
    3
    4
    5
    6
    openElm.addEventListener('click', async () => {
    [fileHandle] = await window.showOpenFilePicker()
    const file = await fileHandle.getFile()
    const contents = await file.text()
    textArea.value = contents
    })

    点击保存时调用writeFile:

    1
    2
    3
    saveElm.addEventListener('click', () => {
    writeFile(fileHandle, textArea.value)
    })

    writeFile函数中创建可写数据流,把textArea的内容写进去:

    1
    2
    3
    4
    5
    6
    7
    8
    async function writeFile(fileHandle, contents) {
    // 创建一个FileSystemWritableFileStream用来写数据
    const writable = await fileHandle.createWritable()
    // 把file的数据写到流中
    await writable.write(contents)
    // 关闭文件并将内容写入磁盘
    await writable.close()
    }

    写数据用到FileSystemWritableFileStream对象,它本质上是一个可写的流,调用fileHandle的createWritable就可以创建,调用createWritable时,浏览器会先检查是否有写的权限,没有的话浏览器就会弹对话框,让用户选择是否开启写权限:

    permission_save

    用户拒绝时createWritable会抛出DOMException的错误:

    DOMException

    这样,应用就不会保存更改。

    上面的writeFile方法在写数据时用的contents是字符串,我们也可以用其它格式的数据,比如BufferSource,或者Blob:

    1
    2
    3
    4
    5
    6
    async function writeURLToFile(fileHandle, url) {
    const writable = await fileHandle.createWritable()
    const response = await fetch(url)
    // 让响应的数据流入文件中,pipeTo默认会关闭管道,不需要手动关闭
    await response.body.pipeTo(writable)
    }

    我们还可以在打开时手动申请写的权限,用户打开文件时看到一个对话框,然后我们对打开的文件就有了读写权限,在保存时就不会再弹对话框。

    通过下面的verifyPermission函数来判断fileHandle是否有读写权限,结果是true则开启了,false则是用户拒绝了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    async function verifyPermission(fileHandle, readWrite) {
    const options = {}
    if (readWrite) {
    options.mode = 'readwrite'
    }
    // 是否开启权限,是就返回true
    if ((await fileHandle.queryPermission(options)) === 'granted') {
    return true
    }
    // 请求开启权限,是就返回true
    if ((await fileHandle.requestPermission(options)) === 'granted') {
    return true
    }
    // 用户拒绝开启权限,返回false
    return false
    }

    另存

    另存会创建一个新的文件。
    调用showSaveFilePicker会弹出保存弹窗:

    1
    await window.showSaveFilePicker()

    保存类型

    types参数控制保存类型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    await window.showSaveFilePicker({
    types: [
    {
    description: 'Text Files',
    accept: {
    'text/plain': ['.txt'],
    },
    },
    ],
    })

    save_as_text

    默认目录

    同样的,我们也可以设置启动目录。如果你是编辑文本,会希望打开或保存时的文件夹路径是文档,如果是编辑图片,则默认图片文件夹,这个路径可以通过配置startIn来实现:

    1
    startIn: 'pictures'

    还有其他目录可以配置:

    • desktop: 桌面
    • documents: 文档
    • downloads: 下载
    • music: 音乐
    • pictures: 图片
    • videos: 视频
      windows中对应文件夹的这些目录:

    startIn

    除了上面这些通用的目录,你还可以设置为存在的文件或目录地址:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const openElm = document.getElementById('open')
    const saveAsElm = document.getElementById('saveAs')
    let fileHandle
    openElm.addEventListener('click', async () => {
    [fileHandle] = await window.showOpenFilePicker()
    })
    saveAsElm.addEventListener('click', async () => {
    window.showSaveFilePicker({
    startIn: fileHandle
    })
    })

    上面的例子中,我们showOpenFilePicker打开了文件,在showSaveFilePicker保存时startIn传打开的fileHandle,即可将保存弹窗的路径设置为和打开时选择的文件路径一致。

    文件夹

    打开文件夹

    showDirectoryPicker可以打开文件夹并获取其中的内容:

    1
    2
    3
    4
    5
    6
    7
    openElm.addEventListener('click', async () => {
    const dirHandle = await window.showDirectoryPicker()

    for await (const entry of dirHandle.values()) {
    console.log(entry)
    }
    })

    如果没有权限,浏览器会弹对话框:

    permission_openDir

    创建文件和文件夹

    在文件夹中,你可以用getFileHandle读取文件,用getDirectoryHandle读取文件夹,在可选参数中传create来控制当新文件和文件夹不存在时是否需要创建。

    1
    2
    3
    4
    5
    6
    7
    // 打开文件夹
    const dirHandle = await window.showDirectoryPicker()
    // 在打开的文件夹中新建名为 "My Documents"的文件夹
    const newDirectoryHandle = await dirHandle.getDirectoryHandle('My Documents', { create: true })
    // 在"My Documents"文件夹中新建名为 "My Notes.txt" 的文件
    const newFileHandle = await newDirectoryHandle.getFileHandle('My Notes.txt', { create: true })

    解析路径

    上面的例子在打开的文件夹中创建文件夹并在新文件夹中新建文件,我们可以解析新建文件的路径:

    1
    2
    const path = await dirHandle.resolve(newFileHandle)
    // path 的值是 ["My Documents", "My Notes.txt"]

    删除

    删除上面新建的My Notes.txt文件:

    1
    await newDirectoryHandle.removeEntry('My Notes.txt')

    删除上面的My Documents文件夹:

    1
    await dirHandle.removeEntry('My Documents', { recursive: true })

    拖拽

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    window.addEventListener('dragover', async (e) => {
    e.preventDefault()
    })
    window.addEventListener('drop', async (e) => {
    e.preventDefault()
    // 遍历多有的item
    for (const item of e.dataTransfer.items) {
    // 注意:文件和文件夹的kind都是file
    if (item.kind === 'file') {
    const entry = await item.getAsFileSystemHandle()
    if (entry.kind === 'directory') {
    // 处理文件夹
    handleDirectoryEntry(entry)
    } else {
    // 处理文件
    handleFileEntry(entry)
    }
    }
    }
    })

    补丁

    还不能给File System Access API打完整的补丁。

    • showOpenFilePicker可以用<input type="file">代替
    • showSaveFilePicker可以用<a download="file_name">代替,尽管这能触发下载,但不能覆盖现有文件
    • showDirectoryPicker可以用<input type="file" webkitdirectory>替代
      browser-fs-access封装了前端文件系统api,会尽量使用File System Access API,不支持的浏览器使用其他方案。

    安全

    Chrome团队设计并实现了文件系统访问API,使用了控制访问强大Web平台功能的核心原则。

    打开或另存文件

    打开文件时,用户通过 file picker 提供读取文件的权限,用来打开的file picker只能用户手动点开,如果用户改变主意了,可以点取消,然后应用不会得到任何用户数据,表现和一样。

    同样的,当应用要另存时,浏览器会弹出保存窗口,让用户指定文件名和位置。

    文件夹限制

    为了保护用户及其数据,浏览器不允许用户保存到一些文件夹,比如核心操作系统文件夹。如果要保存到这些位置,浏览器会弹窗让用户选择其他路径。

    保存

    保存时会覆盖源文件,web应用必须得到用户明确同意后才能保存。

    如果用户要保存对开启了读取权限文件的更改,浏览器就会弹对话框,问用户是否要保存。

    另外,可以编辑多个文件的web应用程序(比如IDE)也可以在打开时请求保存更改的权限。

    如果在对话框中用户点击取消,不给写的权限,那么应用就不能保存更改。这时就需要提供保存的替代方案,让用户可以保存数据,比如提供下载途径,或者保存到云端等等。

    透明

    用户开启应用保存权限后,浏览器会在地址栏显示一个图标,点击图标可以显示开启权限了的文件列表,也可以很方便的取消保存权限。

    有效期

    同一个域名下所有的页面都关闭后,保存权限就没了,用户下一次访问时,会再次弹对话框来询问是否开启权限。

    参考

    • The File System Access API: simplifying access to local files
    ...more
  • 前端那些文件格式

    2021-11-08

    次访问

    前言

    在 Web 开发中,当我们处理文件时(创建,上传,下载),经常会遇到二进制数据。另一个典型的应用场景是图像处理。
    在 JavaScript 中有很多种二进制数据格式,比如ArrayBuffer,Uint8Array,DataView,Blob,File 等等,不过 JavaScript 中的二进制数据是以非标准方式实现的。

    下面我们来了解下这些数据格式及相互转换。
    本文涉及到File,Blob,TypedArray,data url(Base64),blob url等等。

    File

    首先,我们还是拿前文的例子来看,显示用户选择的图片。

    我们创建一个页面。提供选择图片功能。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!DOCTYPE html>
    <html>
    <head>
    <title>test</title>
    </head>

    <body>
    <input type="file" id="fileInput" name="选择图片"/>
    <div class="wrap-image">
    <canvas id="canvas"></canvas>
    </div>
    <script type="text/javascript">

    </script>
    </body>
    </html>

    选择图片后,需要将图片显示到canvas中,我们在上面的script标签中加入下面的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const fileInput = document.getElementById('fileInput')
    const canvas = document.getElementById('canvas')
    fileInput.addEventListener('change', (e) => {
    let img = new Image
    const file = e.target.files[0]
    img.src = URL.createObjectURL(file)
    img.onload = () => {
    canvas.width = img.width
    canvas.height = img.height
    const context = canvas.getContext('2d')
    context.drawImage(img, 0, 0)
    }
    }, false)

    选择一张图片后,change事件中获取到选择的文件e.target.files[0]:

    file

    File 对象是特殊类型的 Blob,可以用在任意的 Blob 类型的 context 中。
    比如 FileReader, URL.createObjectURL(), createImageBitmap(), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。
    File 接口也继承了 Blob 接口的属性。上图的Prototype展开可以看到继承自Blob:

    file_prototype

    上面是最常见的file获取方式————从中获取。
    此外,我们还可以用构造器:

    1
    new File(fileParts, fileName, [options])
    • fileParts —— Blob/BufferSource/String 类型值的数组
    • fileName —— 文件名字符串
    • options —— 可选对象:
      • lastModified —— 最后一次修改的时间戳(整数日期)

    FileReader

    FileReader 的用途是从 Blob(因此也从 File)对象中读取数据。
    它使用事件来传递数据,因为从磁盘读取数据可能比较费时间。

    可以读取为3种格式:

    读取方法 目标格式
    readAsArrayBuffer(blob) 读取为二进制格式的 ArrayBuffer
    readAsText(blob, [encoding]) 读取为给定编码(默认为 utf-8 编码)的字符串
    readAsDataURL(blob) 读取为 base64编码 的 data url

    比如将 Blob 读取为 base64:

    1
    2
    const reader = new FileReader()
    reader.readAsDataURL(file) // 将 Blob 读取为 base64

    使用时选择哪一种,要看如何使用数据。

    读取过程中有下列事件:

    1、loadstart: 开始加载
    2、progress: 在读取过程中出现
    3、load: 读取完成,没有 error
    4、abort: 调用了 abort()取消操作
    5、error: 出现 error
    6、loadend: 读取完成,无论成功还是失败

    使用最广泛的是load和error,比如下面的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <input type="file" onchange="readFile(this)">

    <script>
    const readFile = (input) => {
    const file = input.files[0]
    const reader = new FileReader()
    reader.readAsText(file)
    reader.onload = () => {
    console.log(reader.result) // 结果
    }

    reader.onerror = () => {
    console.log(reader.error) // error
    }
    }
    </script>

    不过大多数情况下,我们不需要读取Blob,通过网络发送一个File很容易,像 XMLHttpRequest 或 fetch 等 API 本身就接受 File 对象。或者用URL.createObjectURL(file) 创建一个短的 url,并将其赋给 或 。这样,文件便可以下载文件或者将其呈现为图像,作为 canvas 等的一部分。

    Blob

    用URL.createObjectURL创建了一个url:

    1
    img.src = URL.createObjectURL(file)

    这里,URL.createObjectURL 取一个 Blob,并为其创建一个唯一的 URL,形式为 blob:/。
    本例中创建的url如下:

    1
    blob:null/a05be8a9-78b4-4470-bdfe-5fca427781c2

    浏览器内部为每个通过 URL.createObjectURL 生成的URL存储了一个 URL → Blob 映射。可以通过URL访问 Blob。

    但 Blob 本身只保存在内存中的。浏览器无法释放它。
    关闭页面时会自动释放内存中的Blob,也可以手动释放,通过URL.revokeObjectURL(url) 从内部映射中移除引用,Blob 被删除,并释放内存。

    映射被删除后该 URL 也就不再起作用了,也就无法通过URL再访问Blob。

    现在,我们了解了Blob作为URL的应用,那Blob到底是什么呢?

    Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成,blobParts是 一系列其他 Blob 对象,字符串和 BufferSource。

    blob

    我们可以通过构造函数创建一个Blob:

    1
    new Blob(blobParts, options)
    • blobParts是 Blob/BufferSource/String 类型的值的数组。
    • options 可选对象:
      • type: Blob 类型,通常是 MIME 类型,例如 image/png,
      • endings:是否转换换行符,使 Blob 对应于当前操作系统的换行符(\r\n 或 \n)。默认为 “transparent”(啥也不做),不过也可以是 “native”(转换)。
        比如从字符串创建 Blob:
    1
    2
    // 注意:第一个参数必须是一个数组 [...]
    const blob = new Blob(['<html>…</html>'], {type: 'text/html'})

    图片 to Blob

    图像操作是通过canvas来实现的:
    1、使用 canvas.drawImage 在 canvas 上绘制图像(绘制后可以做一些图像处理,比如旋转、裁剪等等);
    2、调用 canvas 的 toBlob(callback, format, quality)方法 创建一个 Blob。

    比如,在上面的context.drawImage(img, 0, 0);后,我们把canvas转成Blob:

    1
    2
    3
    canvas.toBlob((blob) => {
    console.log('blob', blob)
    }, 'image/png')

    image_blob

    或者,更喜欢同步的写法:

    1
    2
    3
    4
    5
    6
    img.onload = async () => {
    ...
    context.drawImage(img, 0, 0)
    const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'))
    console.log('blob', blob)
    }

    Blob to Base64

    URL.createObjectURL 的一个替代方法是,将 Blob 转换为 base64。
    base64编码将二进制数据表示为一个由 0 到 64 的 ASCII 码组成的字符串,非常安全且可读。
    我们可以在 data-url 中使用base64编码,data-url就像下面这样:

    1
    data:[<mediatype>][;base64],<data>

    我们可以像常规url一样使用data-url 。
    比如一张支持alpha透明度的webp的图片:

    1
    <img src="data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==">

    我们可以用 FileReader 将 Blob 转换为 base64:

    1
    2
    3
    4
    5
    6
    7
    // img.src = URL.createObjectURL(file)
    const reader = new FileReader()
    reader.readAsDataURL(file) // 将 Blob 转换为 base64 并调用 onload

    reader.onload = () => {
    img.src = reader.result
    }

    现在我们可以从Blob创建2种url,一种是blob url,一种是data url,下面我们对比下这2种方式:

    blob url data url
    创建方式 URL.createObjectURL FileReader
    内存 需手动撤销(revoke) 无需操作
    访问 直接访问 Blob,无需“编码/解码” 对大的 Blob 进行编码时,性能和内存会有损耗

    所以要使用哪种url,需要具体情况再分析。

    ArrayBuffer

    基本的二进制对象 ArrayBuffer是对固定长度的连续内存空间的引用,是一个内存区域,一个原始的二进制数据。

    我们可以这样创建一个长度为 16 的 buffer:

    1
    let buffer = new ArrayBuffer(16); // 创建一个长度为 16 的 buffer

    注意:ArrayBuffer 和 Array 没有任何关系

    我们可以通过 FileReader 的 readAsArrayBuffer 读取 Blob 的二进制数据:

    1
    2
    3
    4
    5
    const reader = new FileReader()
    reader.readAsArrayBuffer(file)
    reader.onload = () => {
    const buffer = reader.result
    }

    arrayBuffer

    或者用Blob的arrayBuffer方法:

    1
    const buffer = await file.arrayBuffer()

    上面我们看到了Int8Array、Uint8Array、Int16Array,它们的通用术语是TypedArray,此外,还有其他的TypedArray。

    TypedArray 用途
    Uint8Array,Uint16Array,Uint32Array 用于 8、16 和 32 位的无符号整数
    Uint8ClampedArray 用于 8 位整数,对于大于 255 的任何数字,它将保存为 255,对于任何负数,它将保存为 0
    Int8Array,Int16Array,Int32Array 用于有符号整数(可以为负数)
    Float32Array,Float64Array 用于 32 位和 64 位的有符号浮点数

    总结

    现在回顾一下:
    1、<input type="file">是最常见的File获取方式;
    2、File对象是特殊类型的 Blob;
    3、Blob可以生成blob url;
    4、FileReader可以读取Blob为3种格式,二进制格式的 ArrayBuffer,给定编码的字符串,base64编码的 data url;
    5、blob url和data url都可以作为url使用;
    6、Blob对象的arrayBuffer方法可以读取Blob的ArrayBuffer;
    7、canvas.toBlob(callback, format, quality)可以把canvas读取为Blob;

    下图可以很直观的看到它们之间的相关转换关系:

    transtorm

    ...more
  • WebGL及其图像处理入门

    2021-10-26

    次访问

    前言

    WebGL仅仅是一个光栅化引擎,它可以根据你的代码绘制出点,线和三角形。
    想要利用WebGL完成更复杂任务,取决于你能否提供合适的代码,组合使用点,线和三角形代替实现。

    本文将带大家了解WebGL基础的绘制流程,并结合之前图片滤镜(基础滤镜和lut滤镜)和图像卷积(模糊、锐化等)的应用,用WebGL来实现。

    获取WebGL

    WebGL基于HTML5 Canvas,所以我们需要使用Canvas作为载体。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!DOCTYPE html>
    <html>
    <head>
    <title>WebGL入门</title>
    </head>
    <body>
    <input type="file" id="fileInput" name="选择图片"/>
    <canvas id="canvas"></canvas>
    <script type="text/javascript">

    </script>
    </body>
    </html>

    再通过getContext方法来获取WebGL上下文。在上面的script标签内加入下面的代码:

    1
    2
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl');

    清空

    1
    2
    gl.clearColor(1.0, 1.0, 0.0, 1.0); // 设置清空颜色缓冲区时的颜色
    gl.clear(gl.COLOR_BUFFER_BIT); // 清空颜色缓冲区

    直接运行上面的2行代码清空,我们可以看到canvas被填充满了黄色。
    因为gl.clearColor中接受RGBA四个值的范围是0~1,所以如果gl.clearColor(0.0, 0.0, 0.0, 1.0)就会填充黑色。

    画点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    function drawPoint() {
    // 1、获取webgl
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl');
    if (!gl) {
    return;
    }
    // 2、清空屏幕
    gl.clearColor(0, 0, 0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    // 3、获取着色器资源
    const vertexSource = document.getElementById('vertex-shader-2d').innerHTML;
    const fragmentSource = document.getElementById('fragment-shader-2d').innerHTML;

    // 4、创建顶点着色器对象
    let vertexShader = gl.createShader(gl.VERTEX_SHADER);
    // 绑定资源
    gl.shaderSource(vertexShader, vertexSource);
    // 编译着色器
    gl.compileShader(vertexShader);

    let fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fragmentShader, fragmentSource);
    gl.compileShader(fragmentShader);

    // 5、创建一个着色器程序
    let program = gl.createProgram();
    // 把前面创建的两个着色器对象加到着色器程序中
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    // 连接着色器程序
    gl.linkProgram(program);

    // 使用程序
    gl.useProgram(program);

    // 6、画点
    gl.drawArrays(gl.POINTS, 0, 1);
    }

    运行drawPoint函数后我们会看到300x300的canvas被填充成黑色,中间有一个10x10的白色点。

    point

    现在来解读下上面的代码。

    分为6步来看,前两步获取webgl并清空屏幕。

    第3步获取OpenGL Shading Language(GLSL)编写的着色程序。

    该语言运行于GPU,是类似于C或C++的强类型语言,它总是成对出现,每对方法中一个叫顶点着色器,另一个叫片断着色器,组合起来称作一个 program(着色程序)

    顶点着色器的作用是计算顶点的位置,根据位置对点, 线和三角形在内的一些图元进行光栅化处理。片断着色器的作用是计算出当前绘制图元中每个像素的颜色值。

    可以利用JavaScript中创建字符串的方式创建GLSL字符串:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 顶点着色器
    const vertexSource = `
    attribute vec4 a_position;
    void main () {
    // gl_Position为内置变量,表示当前点的位置
    gl_Position = a_position;
    // gl_Position为内置变量,表示当前点的大小,为浮点类型
    gl_PointSize = 10.0;
    }
    `;
    // 片段着色器
    const fragmentSource = `
    // 设置浮点数精度
    precision mediump float;
    void main () {
    // vec4是表示四维向量,这里用来表示RGBA的值[0~1],均为浮点数
    gl_FragColor = vec4(1.0, 0.5, 1.0, 1.0);
    }
    `;

    或者跟本文的例子一样,将它们放在非JavaScript类型的标签中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!-- 顶点着色器 -->
    <script type='x-shader/x-vertex' id='vertex-shader-2d'>
    attribute vec4 a_position;
    void main () {
    // gl_Position为内置变量,表示当前点的位置
    gl_Position = a_position;
    // gl_Position为内置变量,表示当前点的大小,为浮点类型,如果赋值是整数类报错
    gl_PointSize = 10.0;
    }
    </script>
    <!-- 片段着色器 -->
    <script type='x-shader/x-fragment' id='fragment-shader-2d'>
    // 设置浮点数精度
    precision mediump float;
    void main () {
    // vec4是表示四维向量,这里用来表示RGBA的值[0~1],均为浮点数,如为整数则错
    gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
    </script>

    这里a_position属性的数据类型是vec4,vec4是一个有四个浮点数据的数据类型。

    GLSL中命名约定:

    • a_ 代表属性,值从缓冲中提供;
    • u_ 代表全局变量,直接对着色器设置;
    • v_ 代表可变量,是从顶点着色器的顶点中插值来出来的。

    获取到着色器资源后,接着创建着色器,绑定资源并编译,然后创建着色程序,把编译好的2个着色器加进来,再连接和使用该着色程序。
    到这一步我们的着色程序就初始化完毕。
    最后就是绘制drawArrays。

    由于drawArrays之前的步骤应用频繁,下面我们把它们封装起来。

    创建着色器

    着色器都是成对出现的,比如本例中的vertexShader和fragmentShader。
    获取着色器资源source后,根据type创建不同的着色器,vertexShader的type是gl.VERTEX_SHADER,fragmentShader的type是gl.FRAGMENT_SHADER。
    然后绑定并编译。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function createShader(gl, type, source) {
    const shader = gl.createShader(type); // 创建 shader 对象
    gl.shaderSource(shader, source); // 往 shader 中传入源代码
    gl.compileShader(shader); // 编译 shader
    // 是否编译成功
    const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (success) {
    return shader;
    }
    console.log(gl.getShaderInfoLog(shader));
    gl.deleteShader(shader);
    }

    创建着色程序

    创建着色程序,把着色器加进来,链接程序。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function createProgram(gl, vertexShader, fragmentShader) {
    const program = gl.createProgram(); // 创建 program 对象
    gl.attachShader(program, vertexShader); // 往 program 对象中传入 WebGLShader 对象
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program); // 链接 program
    // 是否链接成功
    const success = gl.getProgramParameter(program, gl.LINK_STATUS);
    if (success) {
    return program;
    }
    console.log(gl.getProgramInfoLog(program));
    gl.deleteProgram(program);
    }

    然后通过着色器script标签的id,创建连接好的着色程序:

    1
    2
    3
    4
    5
    6
    7
    8
    function createProgramFromScripts (gl, vertexShaderScriptId, fragmentShaderScriptId) {
    const vertexSource = document.getElementById(vertexShaderScriptId).innerHTML;
    const fragmentSource = document.getElementById(fragmentShaderScriptId).innerHTML;
    const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource); // 创建顶点着色器
    const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource); // 创建片元着色器
    const program = createProgram(gl, vertexShader, fragmentShader); // 创建 WebGLProgram 程序
    return program;
    }

    封装好之后,上面的drawPoint函数就可以优化成下面这样:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 1、获取webgl
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl');
    if (!gl) {
    return;
    }

    // 2、清空屏幕
    gl.clearColor(0, 0, 0, 1);
    gl.clear(gl.COLOR_BUFFER_BIT);

    // 3、创建连接好的着色程序
    const program = createProgramFromScripts(gl, 'vertex-shader-2d', 'fragment-shader-2d');

    // 4、使用上面的着色程序
    gl.useProgram(program);

    // 5、画点
    gl.drawArrays(gl.POINTS, 0, 1);

    画多个点

    上面我们画了一个点,现在画多个点。
    比如下面的3个点:

    1
    2
    3
    4
    5
    const points = [
    0, 0.0,
    0.5, 0.0,
    0.0, 0.5
    ];

    需要把这3个点的数据传给webgl:

    1
    2
    3
    4
    5
    // 创建一个buffer,用来放3个点在裁剪空间的坐标
    const buffer = gl.createBuffer();
    // buffer和ARRAY_BUFFER绑定(可以理解成ARRAY_BUFFER = buffer)
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);

    接着获取shader中a_position的地址并做一些配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const positionAttributeLocation = gl.getAttribLocation(program, "a_position"); // 获取shader中a_position的地址
    gl.enableVertexAttribArray(positionAttributeLocation); // 开启attribute
    // 告诉attribute如何从positionBuffer(ARRAY_BUFFER)中读取数据
    gl.vertexAttribPointer(
    positionAttributeLocation, // 属性值a_position的位置
    2, // 每次迭代运行提取两个单位数据
    gl.FLOAT, // 每个单位的数据类型是32位浮点型
    false, // 不需要标准化
    0, // 用符合单位类型和单位个数的大小
    0, // 从缓冲起始位置开始读取
    );

    最后绘制3个点:

    1
    gl.drawArrays(gl.POINTS, 0, 3); // 绘制3个点

    point3

    把上面的绘制3个点改一下,可以绘制成三角形:

    1
    gl.drawArrays(gl.TRIANGLES, 0, 3); // 绘制三角形

    triangle

    因为图元类型primitiveType为三角形gl.TRIANGLES, 顶点着色器每运行三次WebGL将会根据三个gl_Position值绘制一个三角形。

    关于buffer和attribute

    上面我们用到了buffer和attribute,那它们是干什么的呢?

    其实,缓冲操作是GPU获取顶点数据的一种方式。
    gl.createBuffer创建一个缓冲;gl.bindBuffer是设置缓冲为当前使用缓冲; gl.bufferData将数据拷贝到缓冲,这个操作一般在初始化完成。一旦数据存到缓冲中,还需要告诉WebGL怎么从缓冲中提取数据传给顶点着色器的属性。

    首先,我们要获取WebGL给属性分配的地址,这一步一般在初始化时完成。

    1
    2
    // 询问顶点数据应该放在哪里
    const positionAttributeLocation = gl.getAttribLocation(program, 'a_position');

    绘制前还需要发出3个命令。

    1、告诉WebGL我们想从缓冲中提供数据。

    1
    gl.enableVertexAttribArray(location);

    2、将缓冲绑定到 ARRAY_BUFFER 绑定点,它是WebGL内部的一个全局变量。

    1
    gl.bindBuffer(gl.ARRAY_BUFFER, someBuffer);

    3、告诉WebGL从 ARRAY_BUFFER 当前绑定点的缓冲获取数据。

    1
    2
    3
    4
    5
    6
    7
    gl.vertexAttribPointer(
    location,
    numComponents,
    typeOfData,
    normalizeFlag,
    strideToNextPieceOfData,
    offsetIntoBuffer);

    numComponents: 每个顶点有几个单位的数据(1 - 4)
    typeOfData: 单位数据类型是什么(BYTE, FLOAT, INT, UNSIGNED_SHORT, 等等…)
    normalizeFlag: 标准化
    strideToNextPieceOfData: 从一个数据到下一个数据要跳过多少位
    offsetIntoBuffer: 数据在缓冲的什么位置

    如果每个类型的数据都用一个缓冲存储,stride 和 offset 都是 0 。
    stride 为 0 表示 “用符合单位类型和单位个数的大小”。 offset 为 0 表示从缓冲起始位置开始读取。
    它们用 0 以外的值会复杂得多,虽然这样会取得一些性能能上的优势, 但是一般情况下并不值得。

    标准化标记(normalizeFlag)适用于所有非浮点型数据。如果传递false就解读原数据类型。

    坐标转换

    上面的例子中,我们的顶点坐标都是裁剪空间坐标,比如:

    1
    2
    3
    4
    5
    const points = [
    0, 0.0,
    0.5, 0.0,
    0.0, 0.5
    ];

    裁剪空间的x范围是[-1, 1],正方向向右,y的范围也是[-1, 1],正方向向上。

    coordinate_clip

    对于描述二维空间中的物体,比起裁剪空间坐标你可能更希望使用屏幕像素坐标。
    所以我们来改造一下顶点着色器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <script type='x-shader/x-vertex' id='vertex-shader-2d'>
    attribute vec2 a_position;
    uniform vec2 u_resolution;
    void main () {
    // 像素坐标转到 0.0 到 1.0
    vec2 zeroToOne = a_position.xy / u_resolution;

    // 0->1 转到 0->2
    vec2 zeroToTwo = zeroToOne * 2.0;

    // 0->2 转到 -1->+1 (即裁剪空间)
    vec2 clipSpace = zeroToTwo - 1.0;

    gl_Position = vec4(clipSpace, 0, 1);
    }
    </script>

    然后我们用像素坐标表示新的3个点:

    1
    2
    3
    4
    5
    const points = [
    0, 0,
    100, 0,
    0, 100
    ];

    使用program后,我们需要获取vertex-shader-2d中添加的全局变量u_resolution的位置,并设置分辨率:

    1
    2
    3
    const resolutionUniformLocation = gl.getUniformLocation(program, "u_resolution");
    // 设置全局变量 分辨率
    gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

    然后绘制三角形:

    1
    gl.drawArrays(gl.TRIANGLES, 0, 3);

    triabgle_pixi

    这时,我们的坐标系原点在左下角,如果要像传统二维API那样原点在左上角,我们只需翻转y轴:

    1
    2
    // gl_Position = vec4(clipSpace, 0, 1);
    gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); // 翻转y轴

    triabgle_pixi_y

    画矩形

    我们将通过绘制两个三角形来绘制一个矩形,每个三角形有三个点,所以一共有6个点:

    1
    2
    3
    4
    5
    6
    7
    8
    const points = [
    100, 100,
    200, 100,
    100, 200,
    200, 100,
    100, 200,
    200, 200
    ];

    然后绘制时把次数改成6次:

    1
    2
    // 绘制
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    rect

    画图

    改造着色器

    首先我们接着改造上面坐标转换后的顶点着色器,增加a_texCoord和v_texCoord。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    attribute vec2 a_texCoord;
    ...
    varying vec2 v_texCoord;

    void main() {
    ...
    // 将纹理坐标传给片断着色器
    // GPU会在点之间进行插值
    v_texCoord = a_texCoord;
    }

    然后用片断着色器寻找纹理上对应的颜色:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <script id="fragment-shader-2d" type="x-shader/x-fragment">
    precision mediump float;

    // 纹理
    uniform sampler2D u_image;

    // 从顶点着色器传入的纹理坐标
    varying vec2 v_texCoord;

    void main() {
    // 在纹理上寻找对应颜色值
    gl_FragColor = texture2D(u_image, v_texCoord);
    }
    </script>

    加载图片

    点击选择图片按钮后,加载图片,图片加载完成后开始绘制图片。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const inputElement = document.getElementById('fileInput');
    const canvasElement = document.getElementById('canvas');
    fileInput.addEventListener('change', async (e) => {
    const imgElement = document.getElementById('canvas');
    const img = new Image();
    img.src = URL.createObjectURL(e.target.files[0]);
    img.onload = function () {
    imgElement.width = img.width;
    imgElement.height = img.height;
    drawPic(img); // 绘制图片
    }
    }, false);

    绘制图片

    绘制图片我们在drawPic函数中进行,首先获取gl并创建着色程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function drawPic(image) {
    // 获取webgl
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl');
    if (!gl) {
    return;
    }

    // 创建连接好的着色程序
    const program = createProgramFromScripts(gl, 'vertex-shader-2d', 'fragment-shader-2d');
    }

    接着找2个顶点坐标位置(分别是矩形和纹理的坐标):

    1
    2
    let positionLocation = gl.getAttribLocation(program, "a_position");
    let texcoordLocation = gl.getAttribLocation(program, "a_texCoord");

    再画一个和图片一样大小的矩形,首先我们获取画矩形需要的6个点:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const x1 = 0;
    const x2 = image.width;
    const y1 = 0;
    const y2 = image.height;
    const points = [
    x1, y1,
    x2, y1,
    x1, y2,
    x1, y2,
    x2, y1,
    x2, y2,
    ]

    再和上文一样,把点的数据传给webgl,并设置读取方式:

    1
    2
    3
    4
    5
    6
    7
    let positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);
    // 开启 position attribute
    gl.enableVertexAttribArray(positionLocation);
    // 告诉attribute如何从positionBuffer(ARRAY_BUFFER)中读取数据
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    然后创建纹理:

    1
    2
    3
    // 创建纹理
    let texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);

    并对图片渲染做设置,保证可以渲染任何尺寸的图片:

    1
    2
    3
    4
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

    然后把图片加载到上面创建的纹理中:

    1
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

    然后告诉webgl如何从裁剪空间转换到像素空间:

    1
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    然后对a_texCoord的地址并做一些配置。
    渲染纹理时需要纹理坐标,而不是像素坐标,无论纹理是什么尺寸,纹理坐标范围始终是 0.0 到 1.0:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    gl.enableVertexAttribArray(texcoordLocation);

    // 给矩形提供纹理坐标
    let texCoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0.0, 0.0,
    1.0, 0.0,
    0.0, 1.0,
    0.0, 1.0,
    1.0, 0.0,
    1.0, 1.0]), gl.STATIC_DRAW);

    gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);

    接着清空屏幕并使用着色程序:

    1
    2
    3
    4
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    gl.useProgram(program);

    再设置全局变量分辨率:

    1
    2
    let resolutionLocation = gl.getUniformLocation(program, "u_resolution");
    gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);

    到现在就可以画矩形了(6个点画2个三角形组成矩形):

    1
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    到这里图片就出现了:

    effect-original

    简单说就是,我们画了一个和图片一样大的矩形,创建了一个纹理并把图片传到纹理中,再把纹理贴到矩形上,这样图片就显示出来了。

    操作像素

    现在我们对图片做一些简单的像素操作。

    换位

    比如红蓝换位,我们只需要改片段着色器:

    1
    gl_FragColor = texture2D(u_image, v_texCoord).bgra;

    red_blue

    灰度

    解析出颜色通道后,做加权算法,再重新设置颜色:

    1
    2
    3
    vec4 color = texture2D(u_image, v_texCoord);
    float gray = 0.2989 * color.r + 0.5870 * color.g + 0.1140*color.b;
    gl_FragColor = vec4(gray, gray, gray, color.a);

    gray

    更多改变像素颜色的风格算法,可以看看之前写的《前端基础滤镜》。

    颜色查找表
    颜色查找表又叫LUT(look up table),可以实现自定义且多样的风格化滤镜,不清楚的可以看之前写的《前端如何通过LUT实现图片滤镜》。

    首先需要创建一个新的片断着色器,实现LUT算法。(顶点着色器和上面画图的一样)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <script type='x-shader/x-fragment' id='fragment-shader-2d'>
    precision mediump float;
    varying lowp vec2 v_texCoord;
    uniform sampler2D u_image0;
    uniform sampler2D u_image1;
    void main() {
    vec4 textureColor = texture2D(u_image0, v_texCoord);
    float blueColor = textureColor.b * 63.0;
    vec2 quad1;
    quad1.y = floor(floor(blueColor) / 8.0);
    quad1.x = floor(blueColor) - (quad1.y * 8.0);
    vec2 quad2;
    quad2.y = floor(ceil(blueColor) / 8.0);
    quad2.x = ceil(blueColor) - (quad2.y * 8.0);
    vec2 texPos1;
    texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    vec2 texPos2;
    texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.r);
    texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * textureColor.g);
    lowp vec4 newColor1 = texture2D(u_image1, texPos1);
    lowp vec4 newColor2 = texture2D(u_image1, texPos2);
    lowp vec4 newColor = mix(newColor1, newColor2, fract(blueColor));
    gl_FragColor = mix(textureColor, vec4(newColor.rgb, textureColor.w), 1.0);
    }
    </script>

    然后改造下我们的html,除了上传图片,我们还需要上传lut图片,再增加一个应用按钮:

    1
    2
    3
    4
    <input type="file" id="fileInput" />
    <input type="file" id="lutInput" />
    <div id="applyLUT">应用</div>
    <canvas id="canvas"></canvas>

    再给它们绑定事件,上传图片后设置图片地址,点击应用按钮时应用lut滤镜效果:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const fileInput = document.getElementById('fileInput');
    const lutInput = document.getElementById('lutInput');

    const applyElement = document.getElementById('applyLUT');

    let image = new Image();
    let filterImage = new Image();

    fileInput.addEventListener('change', (e) => {
    image.src = URL.createObjectURL(e.target.files[0]);
    }, false);
    lutInput.addEventListener('change', (e) => {
    filterImage.src = URL.createObjectURL(e.target.files[0]);
    }, false);

    applyElement.addEventListener('click', () => {
    applyLUT()
    })

    接下来就是应用lut滤镜函数applyLUT,首先获取gl并创建连接好的着色程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function applyLUT() {
    // 获取webgl
    const canvas = document.getElementById('canvas');
    const gl = canvas.getContext('webgl');
    if (!gl) {
    return;
    }

    // 创建连接好的着色程序
    const program = createProgramFromScripts(gl, 'vertex-shader-2d', 'fragment-shader-2d');
    }

    接着找地址:

    1
    2
    3
    4
    5
    6
    const positionLocation = gl.getAttribLocation(program, 'a_position');
    const texcoordLocation = gl.getAttribLocation(program, 'a_texCoord');

    const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
    const u_image0Location = gl.getUniformLocation(program, 'u_image0');
    const u_image1Location = gl.getUniformLocation(program, 'u_image1');

    然后设置canvas宽高和图片一样:

    1
    2
    canvas.width = image.width;
    canvas.height = image.height;

    再传图片和lut图片数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const image_texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, image_texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBUNSIGNED_BYTE, image);

    const filterImage_texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, filterImage_texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBUNSIGNED_BYTE, filterImage);

    然后设置positionLocation和texcoordLocation:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    const positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0, 0,
    canvas.width, 0,
    0, canvas.height,
    0, canvas.height,
    canvas.width, 0,
    canvas.width, canvas.height,
    ]), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    const texcoordBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0.0, 0.0,
    1.0, 0.0,
    0.0, 1.0,
    0.0, 1.0,
    1.0, 0.0,
    1.0, 1.0,
    ]), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(texcoordLocation);
    gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);

    最后就是清空、使用着色程序、设置窗口等配置及画矩形了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

    gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
    gl.uniform1i(u_image0Location, 0);
    gl.uniform1i(u_image1Location, 1);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, image_texture);
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, filterImage_texture);
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    到这里我们就能看到应用滤镜后的图片,比如我们应用下面这个lut文件:

    Once_upon_a_time

    点击应用后效果:
    lut_filter_1

    卷积

    卷积在图片处理上应用广泛,可以实现比如边缘检测、锐化、模糊等等
    我们将在片断着色器中计算卷积,所以创建一个新的片断着色器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    <script id="fragment-shader-2d" type="x-shader/x-fragment">
    precision mediump float;

    // 纹理
    uniform sampler2D u_image;
    uniform vec2 u_textureSize;
    uniform float u_kernel[9];
    uniform float u_kernelWeight;

    // 从顶点着色器传入的纹理坐标
    varying vec2 v_texCoord;

    void main() {
    vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
    vec4 colorSum =
    texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 1, -1)) * u_kernel[2] +
    texture2D(u_image, v_texCoord + onePixel * vec2(-1, 0)) * u_kernel[3] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 0, 0)) * u_kernel[4] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 1, 0)) * u_kernel[5] +
    texture2D(u_image, v_texCoord + onePixel * vec2(-1, 1)) * u_kernel[6] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 0, 1)) * u_kernel[7] +
    texture2D(u_image, v_texCoord + onePixel * vec2( 1, 1)) * u_kernel[8] ;

    // 只把rgb值求和除以权重
    // 将阿尔法值设为 1.0
    gl_FragColor = vec4((colorSum / u_kernelWeight).rgb, 1.0);
    }
    </script>

    在JavaScript中,我们继续改造上面的drawPic函数,首先找到下面3个地址:

    1
    2
    3
    let textureSizeLocation = gl.getUniformLocation(program, "u_textureSize");
    let kernelLocation = gl.getUniformLocation(program, "u_kernel[0]");
    let kernelWeightLocation = gl.getUniformLocation(program, "u_kernelWeight");

    然后在drawArrays前设置图片大小,提供卷积核,并设置卷积核及其权重:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 设置图片大小
    gl.uniform2f(textureSizeLocation, width, image.height);

    const kernel = [
    -1, -1, -1,
    -1, 8, -1,
    -1, -1, -1
    ]
    // 设置卷积核及其权重
    gl.uniform1fv(kernelLocation, kernel);
    gl.uniform1f(kernelWeightLocation, computeKernelWeight(kernel));
    1
    2
    3
    4
    5
    6
    function computeKernelWeight(kernel) {
    let weight = kernel.reduce(function (prev, curr) {
    return prev + curr;
    });
    return weight <= 0 ? 1 : weight;
    }

    上传图片后就能看到应用卷积核后的效果:

    edge

    下面是一些常见的卷积核:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    let kernels = {
    normal: [
    0, 0, 0,
    0, 1, 0,
    0, 0, 0
    ],
    gaussianBlur: [
    0.045, 0.122, 0.045,
    0.122, 0.332, 0.122,
    0.045, 0.122, 0.045
    ],
    gaussianBlur2: [
    1, 2, 1,
    2, 4, 2,
    1, 2, 1
    ],
    gaussianBlur3: [
    0, 1, 0,
    1, 1, 1,
    0, 1, 0
    ],
    unsharpen: [
    -1, -1, -1,
    -1, 9, -1,
    -1, -1, -1
    ],
    sharpness: [
    0,-1, 0,
    -1, 5,-1,
    0,-1, 0
    ],
    sharpen: [
    -1, -1, -1,
    -1, 16, -1,
    -1, -1, -1
    ],
    edgeDetect: [
    -0.125, -0.125, -0.125,
    -0.125, 1, -0.125,
    -0.125, -0.125, -0.125
    ],
    edgeDetect2: [
    -1, -1, -1,
    -1, 8, -1,
    -1, -1, -1
    ],
    edgeDetect3: [
    -5, 0, 0,
    0, 0, 0,
    0, 0, 5
    ],
    edgeDetect4: [
    -1, -1, -1,
    0, 0, 0,
    1, 1, 1
    ],
    edgeDetect5: [
    -1, -1, -1,
    2, 2, 2,
    -1, -1, -1
    ],
    edgeDetect6: [
    -5, -5, -5,
    -5, 39, -5,
    -5, -5, -5
    ],
    sobelHorizontal: [
    1, 2, 1,
    0, 0, 0,
    -1, -2, -1
    ],
    sobelVertical: [
    1, 0, -1,
    2, 0, -2,
    1, 0, -1
    ],
    previtHorizontal: [
    1, 1, 1,
    0, 0, 0,
    -1, -1, -1
    ],
    previtVertical: [
    1, 0, -1,
    1, 0, -1,
    1, 0, -1
    ],
    boxBlur: [
    0.111, 0.111, 0.111,
    0.111, 0.111, 0.111,
    0.111, 0.111, 0.111
    ],
    triangleBlur: [
    0.0625, 0.125, 0.0625,
    0.125, 0.25, 0.125,
    0.0625, 0.125, 0.0625
    ],
    emboss: [
    -2, -1, 0,
    -1, 1, 1,
    0, 1, 2
    ]
    ;

    也可以看看之前写的《卷积在前端图像处理上的应用》。

    参考

    • WebGL 基础概念
    • WebGL 图像处理
    • webgl-lut-filter
    • webgl-utils
    ...more
  • openCVjs图像处理之自动矫正

    2021-10-21

    次访问

    前文我们写了如何做手动矫正,那需要用户手动框选需要矫正的四边形,而本文,我们简单的实现一种自动矫正,程序检测出图像中的四边形后,自动矫正,不需要手动框选四边形。

    跟上文一样,我们的图片处理程序放在处理按钮的点击事件中:

    1
    2
    3
    changeImageElement.onclick = function () {
    // 图片处理
    }

    首先读取图片:

    1
    let src = cv.imread("imageUpload");

    origin

    接着做一些预处理,先灰度:

    1
    2
    let dst = new cv.Mat();
    cv.cvtColor(src, dst, cv.COLOR_BGR2GRAY, 0); // 转灰度

    gray

    再高斯模糊:

    1
    cv.GaussianBlur(dst, dst, new cv.Size(3, 3), 0); // 高斯模糊

    blur

    再Canny检测边缘:

    1
    cv.Canny(dst, dst, 75, 200); // 边缘检测

    canny

    检测出边缘后,通过findContours找出所有轮廓:

    1
    2
    3
    let contours = new cv.MatVector();
    let hierarchy = new cv.Mat();
    cv.findContours(dst, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE)

    然后从所有轮廓中找到面积最大的闭合轮廓:

    1
    2
    3
    4
    5
    6
    7
    8
    let index = 0, maxArea = 0;
    for (let i = 0; i < contours.size(); ++i) {
    let tempArea = Math.abs(cv.contourArea(contours.get(i)));
    if (tempArea > maxArea) {
    index = i;
    maxArea = tempArea;
    }
    }

    如果把这个找到的轮廓显示出来,就是这样:

    contour

    然后通过上面找到的轮廓进行多边形拟合,从而得到4个顶点:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const foundCours = contours.get(index);
    const arcL = cv.arcLength(foundCtrue);
    let tmp = new cv.Mat();
    // 逼近多边形
    cv.approxPolyDP(foundCours, tmp, 0.01 * arcL, true);
    let points = [];
    if (tmp.total() === 4) {
    const data32S = tmp.data32S;
    for (let i = 0, len = data32S.length / 2; i < len; i++) {
    points[i] = { x: data32S[i * 2], y: data32S[i * 2 + 1] };
    }
    }

    把points打印出来可以看到,已经得到了4个顶点坐标:
    points

    接着对4个顶点进行排序,从左上角开始,顺时针排序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function getSortedVertex(points) {
    const center = {
    x: points.reduce((sum, p) => sum + p.x, 0) / 4,
    y: points.reduce((sum, p) => sum + p.y, 0) / 4
    }
    let sortedPoints = []
    sortedPoints.push(points.find(p => p.x < center.x && p.y < center.y))
    sortedPoints.push(points.find(p => p.x > center.x && p.y < center.y))
    sortedPoints.push(points.find(p => p.x > center.x && p.y > center.y))
    sortedPoints.push(points.find(p => p.x < center.x && p.y > center.y))
    return sortedPoints
    }

    调用上面的getSortedVertex可以得到排序后的顶点,我们将顶点依次放入数组srcPoints中:

    1
    2
    3
    4
    let srcPoints = []
    getSortedVertex(points).forEach(p => {
    srcPoints.push(p.x, p.y)
    })

    接着和上文一样,进行透视变换即可,假设我们输出的图像宽408,高380,那么:

    1
    2
    3
    4
    5
    6
    const dstPoints = [0, 0, 408, 0, 408, 380, 0, 380]
    const srcTri = cv.matFromArray(4, 1, cv.CV_3srcPoints);
    const dstTri = cv.matFromArray(4, 1, cv.CV_3dstPoints);
    const M1 = cv.getPerspectiveTransform(srcTri, dstTri)
    const dsize = new cv.Size(408, 380);
    cv.warpPerspective(src, dst, M1, dsize)

    最后把处理过的图像显示到画布中,不要忘记删除不用的Mat:

    1
    2
    cv.imshow('canvasOutput', dst);
    tmp.delete; src.delete(); dst.delete();

    auto_result

    总结:
    该算法实现比较简单。
    在预处理中,还可以缩小图片,从而加快处理速度,最后找的的顶点坐标再放大。
    也可能无法检测到四边形,从而无法得到4个顶点。
    可以往两个方向优化。
    1、检测时通过识别直线,再筛选直线,来找到四边形;
    2、边缘检测神经网络。
    但复杂的图像识别总有误差的可能,这时候手动就派上用场了。
    不过简单的图像基本ok,比如银行卡照片的矫正:

    card

    ...more
  • openCVjs图像处理之手动矫正

    2021-10-19

    次访问

    本文将带大家看看怎么用openCVjs在前端实现图片手动矫正。就像下面这样,左边是原图,右边是矫正后的图:

    correct_demo

    首先框出需要矫正的四边形区域:
    correct_area

    得到4个顶点,从左上角开始,顺时针排列,分别是p0、p1、p2、p3。
    可以得到他们的准确坐标:

    1
    2
    3
    4
    p0 = [241, 60]
    p1 = [763, 178]
    p2 = [690, 689]
    p3 = [188, 500]

    矫正后的图,假设宽为width,高为height,那么矫正后的图4个顶点是:

    1
    2
    3
    4
    p4 = [0, 0]
    p5 = [width, 0]
    p6 = [width, height]
    p7 = [0, height]

    其中p0~p3与p4~p7对应,我们假设宽width为408,高height为380,可以通过cv.getPerspectiveTransform求出转换矩阵M:

    1
    2
    3
    4
    5
    const srcPoints = [241, 60, 763, 178, 690, 689, 188, 500]
    const dstPoints = [0, 0, 408, 0, 408, 380, 0, 380]
    const srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, srcPoints);
    const dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, dstPoints);
    const M = cv.getPerspectiveTransform(srcTri, dstTri)

    求出转转矩阵后,就可以通过cv.warpPerspective进行透视矫正:

    1
    2
    3
    const dsize = new cv.Size(408, 380);
    cv.warpPerspective(src, dst, M, dsize)
    cv.imshow('canvasOutput', dst)

    这样就得到了处理结果。现在我们看看简单的demo代码。
    首先,初始化一个html:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <!DOCTYPE html>
    <html>
    <head>
    <title>OpenCV.js手动矫正demo</title>
    </head>
    <body>
    <h3 id="status">Loading the Opencv ...</h3>
    <input type="file" id="fileInput" />
    <div id="changeImage">处理</div>
    <div class="wrap-image">
    <img id="imageUpload" alt="No Image" />
    <canvas id="canvasOutput"></canvas>
    </div>
    <script type="text/javascript">
    // TODO
    </script>
    <script async src="js/opencv.js" onload="onOpenCvReady();" type="text/javascript"></script>
    </body>
    </html>

    再在上面的TODO处加入我们的逻辑代码,主要是一些事件绑定。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const imgElement = document.getElementBy('imageUpload');
    const inputElement = document.getElementBy('fileInput');
    const changeImageElement = document.getElement('changeImage');

    inputElement.onchange = function () {
    imgElement.src = URL.createObjectURL(eventarget.files[0]);
    }
    changeImageElement.onclick = function () {
    // 图片处理
    }
    function onOpenCvReady() {
    document.getElementById('status').remove();
    }

    最后在上面的图片处理处加入手动矫正相关代码,点击处理按钮时执行矫正。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const src = cv.imread("imageUpload");
    const dst = new cv.Mat();
    const srcPoints = [241, 60, 763, 178, 690,188, 500]
    const dstPoints = [0, 0, 408, 0, 408, 38380]
    const srcTri = cv.matFromArray(4, 1, cv.CV_3srcPoints);
    const dstTri = cv.matFromArray(4, 1, cv.CV_3dstPoints);
    const M = cv.getPerspectiveTransform(srdstTri)
    const dsize = new cv.Size(408, 380);
    cv.warpPerspective(src, dst, M, dsize)
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    处理完不要忘记删除Mat哦

    下面是原图,感兴趣的可以自己试试看。

    origin

    ...more
  • 前端使用openCV处理图片的基础

    2021-10-15

    次访问

    前言

    前文我们讲了openCV如何在前端应用。
    本文主要根据官方文档的Core Operations部分,带大家了解OpenCV图片处理时需要的一些基础知识,比如mat数据类型的操作,绘制形状等等。

    获取图片属性

    1
    2
    3
    4
    5
    6
    7
    let src = cv.imread('imageUpload');
    console.log('image width: ' + src.cols + '\n' +
    'image height: ' + src.rows + '\n' +
    'image size: ' + src.size().width + '*' + src.size().height + '\n' +
    'image depth: ' + src.depth() + '\n' +
    'image channels ' + src.channels() + '\n' +
    'image type: ' + src.type() + '\n');

    effect-original

    比如上面这张图的属性打印出来就是:

    1
    2
    3
    4
    5
    6
    image width: 600
    image height: 473
    image size: 600*473
    image depth: 0
    image channels 4
    image type: 24

    Mat

    构造Mat

    创建Mat实例时,可以传入size, type或者rows, cols, type,一般用默认构造方式:

    1
    let mat = new cv.Mat();

    或者用数组来构建:

    1
    2
    // 比如: let mat = cv.matFromArray(2, 2, cv.CV_8UC1, [1, 2, 3, 4]);
    let mat = cv.matFromArray(rows, cols, type, array);

    或者用imgData:

    1
    2
    3
    let ctx = canvas.getContext("2d");
    let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    let mat = cv.matFromImageData(imgData);

    另外,还有3个静态函数:

    1
    2
    3
    4
    5
    6
    // 1. 创建一个全是0的Mat
    let mat = cv.Mat.zeros(rows, cols, type);
    // 2. 创建一个全是1的Mat
    let mat = cv.Mat.ones(rows, cols, type);
    // 3. 创建一个单位矩阵的Mat
    let mat = cv.Mat.eye(rows, cols, type);

    Mat实例一定记得要及时删除

    复制Mat

    有2种复制方法:

    1
    2
    3
    4
    // 1. Clone
    let dst = src.clone();
    // 2. CopyTo
    src.copyTo(dst, mask);

    转换Mat类型

    src.convertTo(dst, rtype)
    rtype代表期望的输出矩阵类型,或者说是深度,因为通道的数量与输入相同;如果rtype为负,输出矩阵的类型将与输入矩阵的相同。

    读写像素

    data

    首先,需要了解不同的Data属性在不同语言中的type之间的关系:

    Data属性 C++ Type JavaScript Typed Array Mat Type
    data uchar Uint8Array CV_8U
    data8S char Int8Array CV_8S
    data16U ushort Uint16Array CV_16U
    data16S short Int16Array CV_16S
    data32S int Int32Array CV_32S
    data32F float Float32Array CV_32F
    data64F double Float64Array CV_64F

    通过data获取像素:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let row = 3, col = 4;
    let src = cv.imread("canvasInput");
    if (src.isContinuous()) {
    let index = row * src.cols * src.channels() + col * src.channels()
    let R = src.data[index];
    let G = src.data[index + 1];
    let B = src.data[index + 2];
    let A = src.data[index + 3];
    }

    data操作只对连续的Mat有效,使用前应该用isContinuous函数检查。

    at

    Mat Type At 操作
    CV_8U ucharAt
    CV_8S charAt
    CV_16U ushortAt
    CV_16S shortAt
    CV_32S intAt
    CV_32F floatAt
    CV_64F doubleAt

    通过at获取像素:

    1
    2
    3
    4
    5
    6
    7
    let row = 3, col = 4;
    let src = cv.imread("canvasInput");
    let colIndex = col * src.channels()
    let R = src.ucharAt(row, colIndex);
    let G = src.ucharAt(row, colIndex + 1);
    let B = src.ucharAt(row, colIndex + 2);
    let A = src.ucharAt(row, colIndex + 3);

    at操作只能读,不能写。

    ptr

    Mat Type Ptr 操作 JavaScript Typed Array
    CV_8U ucharPtr Uint8Array
    CV_8S charPtr Int8Array
    CV_16U ushortPtr Uint16Array
    CV_16S shortPtr Int16Array
    CV_32S intPtr Int32Array
    CV_32F floatPtr Float32Array
    CV_64F doublePtr Float64Array

    mat.ucharPtr(k)获取mat的第k行,mat.ucharPtr(i, j)获取mat的第i行第j列。

    1
    2
    3
    4
    5
    6
    7
    let row = 3, col = 4;
    let src = cv.imread("canvasInput");
    let pixel = src.ucharPtr(row, col);
    let R = pixel[0];
    let G = pixel[1];
    let B = pixel[2];
    let A = pixel[3];

    颜色通道操作

    有时我们需要单独操作图片的R/G/B通道,这时就需要对颜色通道进行分割,处理完毕后再合并。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let src = cv.imread("canvasInput");
    let rgbaPlanes = new cv.MatVector();
    // 分割
    cv.split(src, rgbaPlanes);
    // 获取R通道
    let R = rgbaPlanes.get(0);
    // 合并
    cv.merge(rgbaPlanes, src);
    src.delete(); rgbaPlanes.delete(); R.delete();

    坐标点Point

    有2种方式创建一个点:

    1
    2
    let point = new cv.Point(x, y);
    let point = {x: x, y: y};

    像素点Scalar

    1
    2
    let scalar = new cv.Scalar(R, G, B, Alpha);
    let scalar = [R, G, B, Alpha];

    尺寸Size

    1
    2
    let size = new cv.Size(width, height);
    let size = {width : width, height : height};

    圆

    1
    2
    let circle = new cv.Circle(center, radius);
    let circle = {center : center, radius : radius};

    方形

    1
    2
    let rect = new cv.Rect(x, y, width, height);
    let rect = {x : x, y : y, width : width, height : height};

    带旋转角度的方形:

    1
    2
    let rotatedRect = new cv.RotatedRect(center, size, angle);
    let rotatedRect = {center : center, size : size, angle : angle};

    通过下面的方法获取方形的4个顶点:

    1
    2
    3
    4
    5
    let vertices = cv.RotatedRect.points(rotatedRect);
    let point1 = vertices[0];
    let point2 = vertices[1];
    let point3 = vertices[2];
    let point4 = vertices[3];

    通过下面的方法获取方形的边界:

    1
    let boundingRect = cv.RotatedRect.boundingRect(rotatedRect);
    ...more
  • 前端使用openCV之图片处理

    2021-10-12

    次访问

    前言

    前文我们讲了openCV如何在前端应用,本文我们跟着官方文档的图片处理部分,看看能做些什么。

    我们继续在上文的基础上编码,这里简单的回顾一下。

    页面中有个上传图片的按钮:

    1
    <input type="file" id="fileInput"/>

    点击按钮上传图片,触发input的onchange事件:

    1
    2
    3
    4
    5
    let imgElement = document.getElementById('imageUpload');
    let inputElement = document.getElementById('fileInput');
    inputElement.onchange = function() {
    imgElement.src = URL.createObjectURL(event.target.files[0]);
    }

    上传事件中我们设置页面中img标签的图片地址:

    1
    <img id="imageUpload" alt="No Image" />

    由于我们给img绑定了onload事件,设置图片地址后就会触发:

    1
    2
    3
    imgElement.onload = function() {
    // 图片处理程序
    };

    我们的图片处理程序放在onload中,所以上传图片后就自动处理,
    然后页面中有个canvas:

    1
    <canvas id="canvasOutput"></canvas>

    我们通过imshow把处理结果显示到canvas中:

    1
    cv.imshow('canvasOutput', dst);

    这样我们修改onload中的图片处理程序,就可以在上传图片后看到自动处理后的图片,下面我们来看一些例子。

    改变颜色

    转换图片的颜色通道,比如RGB↔Gray,RGB↔HSV等等
    cvtColor
    cv.cvtColor (src, dst, code, dstCn = 0)改变图片的颜色通道。
    其中code参数是颜色转换码,在OpenCV中有150多种可以使用,可以在cv.ColorConversionCodes中查询,比如COLOR_BGR2BGRA、COLOR_BGR2HSV、COLOR_BGR2HLS、COLOR_BayerBG2BGR_EA等等。
    现在我们看看应用最广泛的RGB↔Gray:

    1
    2
    3
    4
    5
    let src = cv.imread('canvasInput');
    let dst = new cv.Mat();
    cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    cv_color_gray

    inRange

    cv.inRange (src, lowerb, upperb, dst)检查颜色是否在范围内。

    1
    2
    3
    4
    5
    6
    7
    let src = cv.imread('canvasInput');
    let dst = new cv.Mat();
    let low = new cv.Mat(src.rows, src.cols, src.type(), [0, 0, 0, 0]);
    let high = new cv.Mat(src.rows, src.cols, src.type(), [150, 160, 200, 255]);
    cv.inRange(src, low, high, dst);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); low.delete(); high.delete();

    cv_color_inRange

    几何变换

    对图片应用不同的几何变换,比如平移、旋转、仿射变换等等

    缩放

    缩放其实就是改变图片大小,OpenCV中用cv.resize (src, dst, dsize, fx = 0, fy = 0, interpolation = cv.INTER_LINEAR)实现缩放,图片大小可以手动填写任意值,或者是缩放系数。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let dsize = new cv.Size(300, 80);
    cv.resize(src, dst, dsize, 0INTER_AREA);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    这里我们通过直接设置图片宽高的方式,把600x473的图片,变成了300x80.

    resize

    平移

    1
    cv.warpAffine (src, dst, M, dsize, flags = cv.INTER_LINEAR, borderMode = cv.BORDER_CONSTANT, borderValue = new cv.Scalar())
    1
    2
    3
    4
    5
    6
    7
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.matFromArray(2, 3, cv.CV_64FC1, [1, 0, 50, 0, 1, 100]);
    let dsize = new cv.Size(src.cols, src.rows);
    cv.warpAffine(src, dst, M, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    translate

    旋转

    和平移一样,也是warpAffine函数,只是M代表的矩阵不同,这里用cv.getRotationMatrix2D(center, angle, scale)计算出旋转矩阵,比如下面以图片中心为旋转点,旋转45°:

    1
    2
    3
    4
    5
    6
    7
    8
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let dsize = new cv.Size(src.cols, src.rows);
    let center = new cv.Point(src.cols / 2, src.rows / 2);
    let M = cv.getRotationMatrix2D(center, 45, 1);
    cv.warpAffine(src, dst, M, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    rotate

    仿射变换

    通过cv.getAffineTransform (src, dst)得到仿射变换的矩阵M,然后调用warpAffine函数。
    getAffineTransform需要输入图像中的3个点在输出图像中的对应点,比如下面的srcTri中的3个点[0, 0, 0, 1, 1, 0]对应dstTri中的[0.6, 0.2, 0.1, 1.3, 1.5, 0.3]。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let srcTri = cv.matFromArray(3, 1, cv.CV_32FC2, [0, 0, 0, 1, 1, 0]);
    let dstTri = cv.matFromArray(3, 1, cv.CV_32FC2, [0.6, 0.2, 0.1, 1.3, 1.5, 0.3]);
    let M = cv.getAffineTransform(srcTri, dstTri);
    let dsize = new cv.Size(src.cols, src.rows);
    cv.warpAffine(src, dst, M, dsize, cv.INTER_LINEAR, cv.BORDER_CONSTANT, new cv.Scalar());
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete(); srcTri.delete(); dstTri.delete();

    transform

    图像阈值化

    cv.threshold(src, dst, thresh, maxval, type)
    如果像素值大于阈值,它被赋一个值(可能是白色),否则被赋另一个值(可能是黑色)。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
    cv.threshold(dst, dst, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    threshold

    图像模糊

    • 自定义滤波
    • 低通滤波模糊图片

    卷积滤波

    和一维信号一样,图像也可以用各种低通滤波器(LPF)、高通滤波器(HPF)等进行滤波。
    LPF有助于去除噪声,模糊图像等。HPF有助于在图像中找到边缘。

    OpenCV提供cv.filter2D(src, dst, ddepth, kernel[, anchor[, delta[, borderType]]])函数来对图片进行卷积核运算。关于卷积,可以看看之前写的《卷积在前端图像处理上的应用》,比如前文的模糊卷积核:

    1
    2
    3
    const kernel = [1 / 9, 1 / 9, 1 / 9,
    1 / 9, 1 / 9, 1 / 9,
    1 / 9, 1 / 9, 1 / 9]; // 模糊卷积核

    这里可以这么用:

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.matFromArray(3, 3, cv.CV_64FC1, [1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9]);
    cv.filter2D(src, dst, cv.CV_8U, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    filter_blur

    下面看看OpenCV内置的4个模糊处理。

    均值模糊

    上面例子中的模糊卷积核,OpenCV提供了cv.blur()函数直接调用。下面的两种写法效果一致。

    1
    2
    3
    4
    5
    6
    7
    // 运用filter2D
    let M = cv.matFromArray(3, 3, cv.CV_64FC1, [1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9]);
    cv.filter2D(src, dst, cv.CV_8U, M);

    // 运用blur
    let ksize = new cv.Size(3, 3);
    cv.blur(src, dst, ksize);

    高斯模糊

    cv.GaussianBlur(src, dst, ksize, sigmaX[, sigmaY[, borderType]])

    不同于均值模糊直接取周围像素的平均值,高斯模糊取像素周围的高斯加权平均值。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let ksize = new cv.Size(3, 3);
    cv.GaussianBlur(src, dst, ksize, 0);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    filter_gaussianBlur

    中值模糊

    cv.medianBlur (src, dst, ksize)
    中值模糊取核内所有像素的中值,中心元素被替换为这个中值。
    这对图像中的椒盐噪声非常有效。
    上面的过滤器,中心元素是一个新计算的值,它可能是图像中的一个像素值,也可能是一个新值。但在中值模糊中,中心元素总是被图像中的某个像素值所替代。它有效地降低了噪声。

    1
    2
    3
    4
    5
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    cv.medianBlur(src, dst, 5);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    filter_medianBlur

    双边滤波

    cv.bilateralFilter(src, dst, d, sigmaColor, sigmaSpace[, borderType])
    双边滤波在保持边缘锐利的同时对去除噪音非常有效。
    但与其他过滤器相比,该操作较慢。
    比如高斯滤波器取像素周围的一个邻域并找到它的高斯加权平均值,在滤波时考虑附近的像素,但不考虑像素是否具有几乎相同的强度,不考虑像素是否是边缘像素。所以边缘也会模糊,这不是我们想要的。

    双边滤波器实际上是2个高斯滤波器组成,一个对周围像素进行模糊,一个确保只有强度差别不大的像素会被模糊处理,所以能在模糊的同时保留边缘。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    cv.cvtColor(src, src, cv.COLOR_RGBA2RGB, 0);
    cv.bilateralFilter(src, dst, 9, 75, 75);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    filter_bilateralBlur

    形态变换

    形态变换是对图片形状的简单运算。它通常在二进制图像上执行。
    它需要两个输入,一个是我们的原始图像,另一个是卷积核。
    两个基本的形态学运算是侵蚀和膨胀。然后它的进阶形式有打开,关闭,梯度等。

    侵蚀

    cv.erode(src, dst, kernel[, anchor[, iterations[, borderType[, borderValue]]]])
    卷积运算时,核内所有像素都为1时运算结果为1,否则为0。所以靠近边缘的像素都被丢弃,图像尺寸会变小。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.Mat.ones(5, 5, cv.CV_8U);
    cv.erode(src, dst, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    erosion

    膨胀

    cv.dilate(src, dst, kernel[, anchor[, iterations[, borderType[, borderValue]]]])
    与侵蚀相反,卷积运算时,核内有一个像素为1时,结果就为1,否则为0。所以图片尺寸会变大。
    通常,在消除噪音时,侵蚀之后是膨胀,因为侵蚀消除了白噪音,但它也缩小了我们的目标,所以需要再放大。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.Mat.ones(5, 5, cv.CV_8U);
    cv.dilate(src, dst, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    dilate

    打开

    1
    cv.morphologyEx(src, dst, op, kernel[, anchor[, iterations[, borderType[, borderValue]]]])

    打开只是侵蚀之后是再膨胀的另一个说法,常在去除噪音时使用。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.Mat.ones(5, 5, cv.CV_8U);
    cv.morphologyEx(src, dst, cv.MORPH_OPEN, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    open

    关闭

    关闭和打开相反,是膨胀之后再侵蚀,对去除图片中的小黑点比较有用。

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.Mat.ones(5, 5, cv.CV_8U);
    cv.morphologyEx(src, dst, cv.MORPH_CLOSE, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    close

    梯度

    与侵蚀和膨胀不同,它的处理结果看起来是形状的边缘。

    1
    2
    3
    4
    5
    6
    7
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    let M = cv.Mat.ones(5, 5, cv.CV_8U);
    cv.cvtColor(src, src, cv.COLOR_RGBA2RGB);
    cv.morphologyEx(src, dst, cv.MORPH_GRADIENT, M);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete(); M.delete();

    gradient

    图像梯度

    OpenCV提供3种图像梯度过滤器:Sobel、Scharr 和 Laplacian。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    let src = cv.imread('imageUpload');
    let dstx = new cv.Mat();
    let dsty = new cv.Mat();
    cv.cvtColor(src, src, cv.COLOR_RGB2GRAY, 0);
    cv.Sobel(src, dstx, cv.CV_8U, 1, 0); // 下图一左
    // cv.Sobel(src, dsty, cv.CV_8U, 0, 1); // 下图一右
    // cv.Scharr(src, dstx, cv.CV_8U, 1, 0); // 下图二左
    // cv.Scharr(src, dsty, cv.CV_8U, 0, 1); // 下图二右
    cv.imshow('canvasOutput', dstx);
    src.delete(); dstx.delete(); dsty.delete();

    下面4张图分别是Sobel和Scharr算子取横向和纵向:
    Sobel
    Scharr

    Laplacian使用的卷积核是:

    1
    2
    3
    const kernel = [0, 1, 0,
    1, -4, 1,
    0, 1, 0];

    先灰度,再Laplacian:

    1
    2
    cv.cvtColor(src, src, cv.COLOR_RGB2GRAY, 0);
    cv.Laplacian(src, dst, cv.CV_8U);

    Laplacian

    注意输出的cv.CV_8U数据类型会导致检测结果不准确,需要用其他格式,比如 cv.CV_16S, cv.CV_64F等等,然后取绝对值再输出。具体的可以参考官网。

    Canny边缘检测

    Canny边缘检测是一种流行的边缘检测算法,由John F. Canny在1986年开发。算法的实现及原理这里就不讲了,我们可以直接调用cv.Canny函数:

    1
    2
    3
    4
    5
    6
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    cv.cvtColor(src, src, cv.COLOR_RGB2GRAY, 0);
    cv.Canny(src, dst, 50, 100);
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    Canny_edge

    图像金字塔

    通常,我们使用固定大小的图像。但有时,我们需要使用不同分辨率的图像。
    例如,当我们在一幅图像中搜索某物时,比如人脸,我们不确定人脸在图像中的大小。
    这时我们需要创建一组具有不同分辨率的图像,并在所有图像中搜索对象。
    这些不同分辨率的图像集合被称为图像金字塔(因为当它们被保存在一个堆栈中,高分辨率高的在底部,分辨率低的在顶部,它看起来像一个金字塔)。

    降低分辨率

    1
    2
    3
    4
    5
    let src = cv.imread('imageUpload');
    let dst = new cv.Mat();
    cv.pyrDown(src, dst, new cv.Size(0, 0));
    cv.imshow('canvasOutput', dst);
    src.delete(); dst.delete();

    down_sample

    提高分辨率

    1
    cv.pyrUp(src, dst, new cv.Size(0, 0));

    傅里叶变换

    傅里叶变换常用于图片的频域分析。
    代码较多,这里就不详细分析了。
    Fourier

    总结

    上面列了OpenCV的一些基础的图像处理接口,比如几何变换、阈值化、形态变换等等,在实际应用中,通过这些接口,可以实现图片频域分析及处理、图像分割、图片匹配、图片轮廓或边缘检测等等。比如对图片频域加盲水印会用到傅里叶变换。

    ...more
  • 卷积在前端图像处理上的应用

    2021-09-29

    次访问

    前言

    前文我们了解了前端图像处理时对矩阵的应用,通过仿射矩阵对canvas做变换处理。
    现在我们深入一下,通过矩阵,进行卷积运算,对canvas进行更高级的处理,比如边缘检测、锐化、模糊等等。
    首先我们先了解一下二维卷积层的工作原理。

    互相关运算

    大学毕业多年,大部分同学对卷积的了解就剩“卷积”2个字了。
    比如课本中对连续卷积的定义公式是:

    convolution_1

    对离散卷积的定义是:
    convolution_2

    都忘了对吧?没关系,我们接着往下看。

    在图像处理中,我们用到的一般是互相关运算。下面我们看看《动手学深度学习》中的例子。

    第一步:两个二维矩阵做某种特殊的乘法,输出第一个元素:0×0+1×1+3×2+4×3=19
    convolution_3

    第二步:向右移动输入矩阵的深色部分,得到第二个输出元素。同样,计算的过程也是输入矩阵的深色部分与核一一相乘。
    convolution_4

    输出中的各个元素是按照下面的方法算出:

    1
    2
    3
    4
    0 × 0 + 1 × 1 + 3 × 2 + 4 × 3 = 19
    1 × 0 + 2 × 1 + 4 × 2 + 5 × 3 = 25
    3 × 0 + 4 × 1 + 6 × 2 + 7 × 3 = 37
    4 × 0 + 5 × 1 + 7 × 2 + 8 × 3 = 43

    用动图演示,输入矩阵和核矩阵之间的卷积操作如下:
    convolution_5

    这种输入矩阵与核矩阵之间的相乘被称作为互相关(Cross-Correlation)运算。

    下面我们看看互相关运算的程序实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 卷积计算函数
    function convolutionMatrix(output, input, kernel) {
    let w = input.width, h = input.height;
    let iD = input.data, oD = output.data;
    for (let y = 1; y < h - 1; y += 1) {
    for (let x = 1; x < w - 1; x += 1) {
    for (let c = 0; c < 3; c += 1) {
    let i = (y * w + x) * 4 + c;
    oD[i] = kernel[0] * iD[i - w * 4 - 4] +
    kernel[1] * iD[i - w * 4] +
    kernel[2] * iD[i - w * 4 + 4] +
    kernel[3] * iD[i - 4] +
    kernel[4] * iD[i] +
    kernel[5] * iD[i + 4] +
    kernel[6] * iD[i + w * 4 - 4] +
    kernel[7] * iD[i + w * 4] +
    kernel[8] * iD[i + w * 4 + 4];
    }
    oD[(y * w + x) * 4 + 3] = 255;
    }
    }
    return output;
    }

    这里的output和input都是图片的imageData数据,从左到右,从上到下,遍历图片,把像素点存在data数组里,每个像素点由r、g、b、a一共4个值组成,canvas像素操作这里就不赘述了,不清楚的可以去看之前写的《前端如何在像素级别操纵图片》。

    imageData

    kernel是3x3的核矩阵。

    所以我们计算oD(输出数据)中某一点的值,由上面的动图演示可以直观的看到,还需要这个点周围的8个点的数据。而一个点又由r、g、b、a这4个参数组成,所以我们需要对不同的数据通道分别进行卷积运算,这里不需要处理透明度a的值,直接赋值为255。
    所以上面的程序简单说就是,2个嵌套的for循环来遍历像素点(注意遍历时从1开始而不是0):

    1
    2
    3
    4
    5
    for (let y = 1; y < h - 1; y += 1) {
    for (let x = 1; x < w - 1; x += 1) {

    }
    }

    遍历到某个点时,通过c的遍历分别对r、g、b通道进行卷积求值,c为0时,操作的是r通道,1时是g,2时是b。

    这个点的r/g/b/a值在imageData.data数组中的下标是(y * w + x) * 4 + c。
    其中y是该点在图片中的行,行乘w(图片宽)得到该点所在行上方点的数量,再加x(该点在图片中的列),就能得到该点在所有点中的排位,由于每个点有4个值,所以还要乘4,那么从(y * w + x) * 4开始的4个值就是该点的rgba((y * w + x) * 4 + c中c分别取0,1,2,3)。
    对于下标i的值,其左侧点对应的值是i - 4,右侧是i + 4。
    上方的点需要减一行,一行的点对应的值有w * 4个,所以正上方的点对应的值是i - w * 4,同理正下方是i + w * 4,对这两个点减4加4,就得到它们左右两点。位置如下图:

    convolution_7

    然后对他们做卷积互相关运算得到oD[i]。
    convolution_8

    1
    2
    3
    4
    5
    6
    7
    8
    9
    oD[i] = kernel[0] * iD[i - w * 4 - 4] +
    kernel[1] * iD[i - w * 4] +
    kernel[2] * iD[i - w * 4 + 4] +
    kernel[3] * iD[i - 4] +
    kernel[4] * iD[i] +
    kernel[5] * iD[i + 4] +
    kernel[6] * iD[i + w * 4 - 4] +
    kernel[7] * iD[i + w * 4] +
    kernel[8] * iD[i + w * 4 + 4];

    这样我们就对点通过核矩阵做了某个处理,从而处理了整张图片。
    下面我们怎么调用这个函数。
    在前面写过的《前端基础滤镜》一文中,曾经封装过一个CanvasImage类,这里我们可以增加一个convolution方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class CanvasImage {
    constructor(img, context) {
    this.image = img;
    this.context = context;
    }

    getData() {
    return this.context.getImageData(0, 0, this.image.width, this.image.height);
    }
    setData(data) {
    this.context.putImageData(data, 0, 0);
    }
    convolution() {
    // TODO 后文再完善
    }
    }

    convolution方法中,我们调用卷积计算函数convolutionMatrix:

    1
    2
    3
    4
    5
    convolution(kernel) {
    const imageData = this.getData()
    const outData = convolutionMatrix(this.context.createImageData(imageData), imageData, kernel)
    this.setData(outData)
    }

    然后我们调用convolution,需要一个核矩阵,比如一个锐化卷积核:

    1
    2
    3
    const kernel = [-1, -1, -1,
    -1, 9, -1,
    -1, -1, -1]; // 锐化卷积核

    接着我们创建一个CanvasImage的实例filter

    1
    filter = new CanvasImage(img, context)

    然后我们就可以调用filter的convolution方法:

    1
    filter.convolution(kernel)

    就可以看到图片被锐化处理了。(左侧是原图)

    convolution_6

    填充与步幅

    现在我们知道,对图片数据(输入矩阵)进行卷积时,一般是使用一个卷积核矩阵进行互相关运算。
    比如图一和图二中,我们使用高和宽为3的输入与高和宽为2的卷积核得到高和宽为2的输出。
    一般来说,假设输入形状是nh x nw,卷积核形状是kh x kw,那么输出形状将是(nh - kh + 1)*(nw - kw + 1)。
    所以输出形状由输入形状和卷积核形状决定。
    接下来我们看看卷积层的两个超参数:填充(Padding)和步幅(Strides)。

    填充 Padding

    Padding是指在输入高和宽的两侧填充元素(通常是0)。

    一般来说,在上下一共填充ph行,在左右共填充pw列,那么输出形状就是(nh - kh + 1 + ph) * (nw - kw + 1 + pw),即输出宽高分别增加ph和pw。

    通常我们用的卷积核宽高都是奇数,比如1、3、5、7,为了使输入和输出的宽高相同,一般会设置ph = kh - 1和pw = kw - 1,这样两端填充的个数就相等,分别是 ph / 2和pw / 2。

    比如一个尺寸6 x 6的数据矩阵,经过padding后,尺寸变为8 * 8,卷积运算后输出尺寸为6 x 6,保证了图片尺寸不变化。
    convolution_9

    步幅 Stride

    上面动图演示的卷积例子中,卷积核矩阵从输入矩阵的左上方开始,按从左往右、从上往下的顺序,依次在输入矩阵上滑动。我们将每次滑动的行数和列数称为步幅(Stride)。

    目前为止,我们看到的例子,在高和宽两个方向上步幅均为1。
    下图是在纵向上步幅为3、在横向上步幅为2的二维互相关运算:
    convolution_10

    可以看到,在输出第2个元素时,卷积窗口向右滑动了2列,计算出结果是0×0 + 0×1 + 1×2 + 2×3 = 8。
    在输出第3个元素时,卷积窗口向下滑动了3行,计算出结果是0×0 + 6×1 + 0×2 + 0×3 = 6。

    一般来说,当高上的步幅为sh,宽上的步幅为sw时,输出形状为[(nh - kh + ph + sh) / sh] * [(nw - kw + pw + sw) / sw]。
    比如,如果让sh和sw都为2,那么输出矩阵的宽高会只有输入矩阵的一半。

    卷积核

    上面介绍了卷积互相关运算及填充和步幅相关知识,下面我们来看看卷积核。
    经过多年的研究,人们已经能够设计出不同的核矩阵,对图片进行转换,以达到不同的效果。不过,在深度学习出现之前,卷积核是人工设计的,需要消耗大量的时间和精力,然而深度学习出现之后,我们为卷积核初始化一些随机值,通过机器学习训练就可以得到卷积核。

    卷积核特性

    1、大小一般是奇数,这样它才有一个中心,例如3x3,5x5或者7x7。
    2、卷积核上的每一位数称为权值,它们决定了这个像素的分量有多重。
    3、它们的总和加起来如果等于1,计算结果不会改变图像的灰度强度。
    4、如果大于1,会增加灰度强度,计算结果使得图像变亮。
    5、如果小于1,会减少灰度强度,计算结果使得图像变暗。
    6、如果和为0,计算结果图像不会变黑,但也会非常暗。

    接下来我们看一些常见的卷积核。

    边缘检测

    比如常用的高斯-拉普拉斯算子:

    1
    2
    3
    4
    5
    6
    7
    8
    // 可侦测水平和垂直边缘
    const kernel1 = [0, -1, 0,
    -1, 5, -1,
    0, -1, 0];
    // kernel1的基础上,还可侦测对角线的边缘,即斜的边缘
    const kernel2 = [-1, -1, -1,
    -1, 8, -1,
    -1, -1, -1];

    convolution_12

    图片的边缘是图像的最基本特征,所谓边缘是指其周围像素灰度有阶跃变化或屋顶变化的那些像素的集合。
    边缘的种类可以分为两种:一种称为阶跃性边缘,它两边的像素的灰度值有着显著的不同;另一种称为屋顶状边缘,它位于灰度值从增加到减少到变化转折点。

    我们能感受到物体的边缘,是因为边缘有明显的色差。比如输入图像的部分色值为10,部分色值为50,那么10和50之间就存在色差,边缘就在这个地方。
    经过卷积计算之后,我们可以看到色值相同的部分都变成了0,表现为黑色,只有边缘的色值计算结果大于0(色值最小是0,负数色值也是黑色),即色值为120的边缘就凸显出来了。

    convolution_11

    除了高斯-拉普拉斯算子,还有Roberts、Sobel、Prewit、Kirsch等边缘算子。

    但是高斯-拉普拉斯算子只需要一个算子,而其余的需要多个算子,然后取最大值,计算较为复杂,高斯-拉普拉斯算子对噪音敏感,可以先做模糊处理,即blur + Laplacian。

    此外还有著名的Canny边缘检测算法,这里就不细说了。

    锐化

    锐化也是一种针对边缘处理(增强)的效果,
    简单的锐化处理可以把边缘检测卷积核中间的8改为9。

    1
    2
    3
    const kernel = [-1, -1, -1,
    -1, 9, -1,
    -1, -1, -1]; // 锐化卷积核

    或者,只让中心点与上下左右4个点过度的更加粗糙:

    1
    2
    3
    const kernel = [0, -1, 0,
    -1, 5, -1,
    0, -1, 0];

    但是这些锐化效果都不是很好,会使噪点大量增多。

    模糊

    1
    2
    3
    const kernel = [1 / 9, 1 / 9, 1 / 9,
    1 / 9, 1 / 9, 1 / 9,
    1 / 9, 1 / 9, 1 / 9]; // 模糊卷积核

    值全为1/9的矩阵,意思是把周边元素和中心元素做了一个平均数,从而使点间过渡更加光滑,也就实现了模糊。这也称为高斯平滑滤波。

    convolution_13

    浮雕

    1
    2
    3
    const kernel = [-2, -1, 0,
    -1, 1, 1,
    0, 1, 2]; // 浮雕卷积核

    convolution_14

    参考

    • 二维卷积层入门:卷积运算、填充与步幅、输入输出通道
    • 前端图像处理之滤镜
    ...more
  • canvas变换矩阵

    2021-09-27

    次访问

    前言

    在计算机图形学的各种应用中都能找到矩阵的身影。它广泛应用于计算机视觉过滤器、图像处理(比如边缘检测)、锐化以及模糊变换。随着你不断地深入到更加高级的计算图形编程中,你会发现更多有关矩阵的应用。
    本文主要写如何在canvas中进行变换(平移、缩放、倾斜),再延伸到矩阵的应用。

    状态的保存和恢复

    canvas中有两个绘制复杂图形时必不可少的方法:save()和restore()。
    save()保存画布所有状态。调用后,将当前画布状态存到栈中。
    restore()调用时将上一次save的状态从栈中取出并应用。
    在做变形之前先保存状态是一个良好的习惯,做完变形再restore恢复即可。

    当前画布状态包括:
    1、当前应用的变形(即移动,旋转和缩放)
    2、下列属性:
    strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled
    3、当前的裁切路径(clipping path)

    如果对save和restore应用还不够清楚,接下来一起看看MDN上的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function draw() {
    var ctx = document.getElementById('canvas').getContext('2d');

    ctx.fillRect(0, 0, 150, 150);
    ctx.save();

    ctx.fillStyle = '#09F';
    ctx.fillRect(15, 15, 120, 120);
    ctx.save();

    ctx.fillStyle = '#FFF';
    ctx.globalAlpha = 0.5;
    ctx.fillRect(30, 30, 90, 90);

    ctx.restore();
    ctx.fillRect(45, 45, 60, 60);

    ctx.restore();
    ctx.fillRect(60, 60, 30, 30);
    }

    代码中共绘制了5次方形,每次fillRect之后对应下面的5张图:

    canvas_save_restore

    translate

    translate(x, y)用来移动 canvas 和它的原点到一个不同的位置。
    Canvas_grid_translate

    1
    2
    3
    4
    // ctx.fillRect(60,60,30,30);
    // 可以改成下面的写法
    ctx.translate(60, 60);
    ctx.fillRect(0, 0, 30, 30);

    rotate

    rotate(angle),顺时针方向,以原点为中心旋转把canvas旋转angle弧度。
    (注意旋转中心是原点,单位是弧度,方向是顺时针)

    Canvas_grid_rotate

    弧度与角度

    2PI 弧度 = 360 度

    1
    2
    3
    4
    5
    6
    7
    8
    // degrees 角度
    // radians 弧度
    function degreesToRadians (deg) {
    return deg * Math.PI / 180
    }
    function radiansToDegrees (rad) {
    return rad * 180 / Math.PI
    }

    scale

    scale(x, y)缩放画布的水平和垂直的单位。x/y可以是负数,负数时以x/y轴镜像翻转。

    默认情况下,canvas 的 1 个单位为 1 个像素。
    如果我们设置缩放因子是 0.5,1 个单位就变成对应 0.5 个像素,这样绘制出来的形状就会是原先的一半。同理,设置为 2.0 时,1 个单位就对应变成了 2 像素,绘制的结果就是图形放大了 2 倍。
    scale(1,-1)就以y轴作为对称轴镜像翻转。canvas画布左上角是原点,如果translate(0, canvas.height)将原点沿x轴移到画布底部,再scale(1, -1)将画布以y轴翻转,就可以得到笛卡尔坐标系(左下角为原点)。

    transform

    transform(a, b, c, d, e, f)对当前画布应用该矩阵,其中:

    • a: 水平方向的缩放scaleX
    • b: 竖直方向的倾斜偏移skewY
    • c: 水平方向的倾斜偏移skewX
    • d: 竖直方向的缩放scaleY
    • e: 水平方向的移动translateX
    • f: 竖直方向的移动translateY

    canvas_transform

    setTransform(a, b, c, d, e, f)取消当前变形,然后设置为指定的变形。
    resetTransform()重置当前变形为单位矩阵,即取消当前变形。等同于setTransform(1, 0, 0, 1, 0, 0),即scale都是1,无旋转,无平移。

    补充完上面这些基础,下面我们来看看矩阵数学的应用。

    矩阵

    矩阵被大量应用于 3D 系统中,以实现旋转、缩放以及平移 3D 坐标的功能。它也常用于各种 2D 图形的变换。

    首先我们复习下线代中学到的矩阵的基本算法。

    矩阵加/减法就是相同位置的数字相加/减:
    matrix_plus
    矩阵乘以矩阵:
    matrix_multiply
    结果矩阵第m行与第n列交叉位置的那个值,等于第一个矩阵第m行与第二个矩阵第n列,对应位置的每个值的乘积之和:
    matrix_multiply_formula

    矩阵的加减可以用于平移。
    比如一个点(x, y, z),看作是1x3的矩阵(x y z),假如dx、dy、dz 分别为 x、y、z 轴上的移动距离,距离也看作一个1x3的矩阵(dx dy dz)。那么我们将这两个矩阵相加,就可以得到平移后的点。

    矩阵乘法通常用于缩放和旋转。
    比如,(w h d)分别对应一个物体现在3条轴上的值(宽、高、深)。
    应用下面的缩放矩阵(sx、sy、sz 分别为对应轴上的缩放比例):
    multiply_a
    就需要用乘法:
    multiply_scale
    如果要把点(x, y, z)旋转,我们需要一个旋转矩阵,通过旋转矩阵,在3条轴中任意一条轴上旋转。
    比如围绕x轴的旋转矩阵:
    multiply_rotate_x
    这里的 cos 与 sin 代表要旋转的角度(以弧度为单位)的余弦和正弦值。

    和点(x, y, z)相乘:
    multiply_rotate_x_result

    所以点(x, y, z)以x轴旋转后的点是(x, y * cos - z * sin, y * sin + z * cos)。
    假设绕x轴旋转45°,45°转成弧度是π / 4,所以用js表示就是:

    1
    2
    3
    x = x
    y = y * Math.cos(Math.PI / 4) - z * Math.sin(Math.PI / 4)
    z = y * Math.sin(Math.PI / 4) + z * Math.cos(Math.PI / 4)

    围绕 y 轴旋转的矩阵:
    multiply_rotate_y

    围绕 z 轴旋转的矩阵:
    multiply_rotate_z

    仿射变换

    canvas 的context变换使用下面这个 3x3 的变换矩阵,也叫做仿射变换:
    multiply_b

    为了能应用仿射变换,二维向量 (x, y) 需要改写为三维向量 (x, y, 1)。由于 (u, v, w) 并不会用到,他们会直接设为 (0, 0, 1),并保持不变。
    可以通过调用ctx.setTransform(a, b, c, d, dx, dy)设置 canvas 上下文的变换矩阵,或者ctx.transform(a, b, c, d, dx, dy)累计变换效果(不取消当前变形)。

    如果没有为 canvas 设置任何变换矩阵,那么 canvas 会认为我们使用了一个单位矩阵(identity matrix)或一个空矩阵,就是类似下面这样一个矩阵:
    multiply_ampty

    应用空矩阵,相当于ctx.setTransform(1, 0, 0, 1, 0, 0)。

    仿射变换矩阵中的dx 和 dy 控制 canvas 上下文将要在 x 与 y 轴上平移的距离,而 a、b、c、d 则有点复杂,还可以将 a、b、c、d 联合起来设置成下面这个我们熟悉的旋转矩阵(上文的绕z轴旋转矩阵):
    multiply_rotate_2d

    文章开头的例子中,最后我们绘制了一个小的黑色方形:

    1
    ctx.fillRect(60, 60, 30, 30);

    如果我们要以方形的中心点旋转45度,可以将上面的ctx.fillRect(60, 60, 30, 30)换成下面的写法:

    1
    2
    3
    4
    var sin = Math.sin(Math.PI / 4);
    var cos = Math.cos(Math.PI / 4);
    ctx.transform(cos, sin, -sin, cos, 75, 75);
    ctx.fillRect(-15, -15, 30, 30);

    canvas_rotate_45

    这里的Math.PI / 4改成直接调上面写过的角度与弧度的换算函数degreesToRadians(45),会更好理解是旋转45°。

    推导旋转公式

    下面我们以2D平面旋转为例,推导旋转公式(即canvas旋转矩阵的推导)。
    首先我们回顾下canvas旋转矩阵:

    multiply_rotate_z

    点(x, y)应用该矩阵:
    multiply_rotate_canvas_result

    结果是点(x, y)变成点(x * cos - y * sin, x * six + y * cos)

    下面我们就一步步推导。

    我们已知点(x, y)要围绕中心点(0, 0)旋转rotation角度。
    求旋转后的点的坐标(x1, y1)。

    设点(x, y)到原点的距离是radius,与x轴的夹角是angle。
    那么:

    1
    2
    3
    4
    5
    x = radius * cos(angle)
    y = radius * sin(angle)

    x1 = radius * cos(angle + rotation)
    y1 = radius * sin(angle + rotation)

    接下来运用cos(a + b)和sin(a + b)的展开公式把x1和y1展开:

    1
    2
    x1 = radius * cos(angle) * cos(rotation) - radius * sin(angle) * sin(rotation)
    y1 = radius * sin(angle) * cos(rotation) + radius * cos(angle) * sin(rotation)

    再把x = radius * cos(angle)和y = radius * sin(angle)带入得:

    1
    2
    x1 = x * cos(rotation) - y * sin(rotation)
    y1 = y * cos(rotation) + x * sin(rotation)

    可以看到(x1, y1)的值和运用旋转矩阵后的值一致。
    矩阵只是组织各种公式与方程的另一种方法而已,所以并没有什么不同。

    参考

    • 变形 Transformations
    • HTML5 + JavaScript 动画基础
    • 理解矩阵乘法
    ...more

分类归档

  • ai3
  • css2
  • doc2
  • git1
  • js15
  • node2
  • 动效6
  • 图像处理13

标签云

最近文章

  • js遍历那些事儿
  • 详解CSS Grid布局
  • Shutterstock Editor关于Canvas和WebGL图片滤镜的实现
  • javascript异步error
  • win10编译opencvjs
PREVNEXT

© 2018 - 2024 Lovelyun