使用画布操作像素

Avatar of Welling Guzman
Welling Guzman

DigitalOcean 提供适用于旅程各个阶段的云产品。立即开始使用 $200 免费积分!

现代浏览器支持通过<video>元素播放视频。大多数浏览器还可以通过 MediaDevices.getUserMedia() API 访问网络摄像头。但是,即使将这两者结合在一起,我们也无法真正直接访问和操作这些像素。

幸运的是,浏览器具有 画布 API,允许我们使用 JavaScript 绘制图形。我们实际上可以从视频本身绘制图像到<canvas>,这使我们能够操作和玩弄这些像素。

您在这里学到的有关如何操作像素的所有知识都将为您提供一个基础,您可以使用任何类型或任何来源的图像和视频,而不仅仅是画布。

将图像添加到画布

在我们开始播放视频之前,让我们看看如何将图像添加到画布中。

<img id="SourceImage" src="image.jpg">
<div class="video-container"></div>

我们创建了一个图像元素,它代表要绘制到画布上的图像。或者,我们可以使用 JavaScript 中的Image对象。

var canvas;
var context;

function init() {
  var image = document.getElementById('SourceImage');
  canvas = document.getElementById('Canvas');
  context = canvas.getContext('2d');

  drawImage(image);
  // Or
  // var image = new Image();
  // image.onload = function () {
  //    drawImage(image);
  // }
  // image.src = 'image.jpg';
}

function drawImage(image) {
  // Set the canvas the same width and height of the image
  canvas.width = image.width;
  canvas.height = image.height;

  context.drawImage(image, 0, 0);
}

window.addEventListener('load', init);

上面的代码将整个图像绘制到画布上。

现在我们可以开始玩弄这些像素了!

更新图像数据

画布上的图像数据允许我们操作和更改像素。

data 属性是一个 ImageData 对象,它具有三个属性——widthheightdata/,所有这些属性都基于原始图像代表这些内容。所有这些属性都是只读的。我们关心的一个是data,一个用 Uint8ClampedArray 对象表示的一维数组,包含每个像素的以 RGBA 格式表示的数据。

虽然data 属性是只读的,但这并不意味着我们不能更改它的值。这意味着我们不能将另一个数组分配给此属性。

// Get the canvas image data
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);

image.data = new Uint8ClampedArray(); // WRONG
image.data[1] = 0; // CORRECT

您可能会问,Uint8ClampedArray 对象代表什么值。以下是 MDN 中的描述

Uint8ClampedArray 类型数组表示一个 8 位无符号整数数组,这些整数被限制在 0-255 之间;如果您指定的值超出 [0,255] 的范围,则将设置 0 或 255;如果您指定一个非整数,则将设置最接近的整数。内容被初始化为0。一旦建立,您可以使用对象的函数或使用标准数组索引语法(即使用方括号表示法)引用数组中的元素。

简而言之,此数组存储每个位置的值范围为 0 到 255,这使得它成为 RGBA 格式的完美解决方案,因为每个部分都用 0 到 255 的值表示。

RGBA 颜色

颜色可以用 RGBA 格式表示,它是红色、绿色和蓝色的组合。A代表 alpha 值,它是颜色的透明度。

数组中的每个位置都代表一个颜色(像素)通道值。

  • 第一个位置是红色值
  • 第二个位置是绿色值
  • 第三个位置是蓝色值
  • 第四个位置是 Alpha 值
  • 第五个位置是下一个像素的红色值
  • 第六个位置是下一个像素的绿色值
  • 第七个位置是下一个像素的蓝色值
  • 第八个位置是下一个像素的 Alpha 值
  • 依此类推…

如果您有一个 2×2 的图像,那么我们有一个 16 个位置的数组(2×2 像素 × 每个 4 个值)。

2×2 图像在放大后的样子

数组将按如下所示表示

// RED                 GREEN                BLUE                 WHITE
[ 255, 0, 0, 255,      0, 255, 0, 255,      0, 0, 255, 255,      255, 255, 255, 255]

更改像素数据

我们可以做的最快的操作之一是通过将所有 RGBA 值更改为 255 来将所有像素设置为白色。

// Use a button to trigger the "effect"
var button = document.getElementById('Button');

button.addEventListener('click', onClick);

function changeToWhite(data) {
  for (var i = 0; i < data.length; i++) {
    data[i] = 255;
  }
}

function onClick() {
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  
  changeToWhite(imageData.data);

  // Update the canvas with the new data
  context.putImageData(imageData, 0, 0);
}

data 将作为引用传递,这意味着我们对它进行的任何修改,都将改变传递的参数的值。

反转颜色

一个不需要太多计算的不错的效果是反转图像的颜色。

反转颜色值可以使用 XOR 运算符 (^) 或以下公式完成 255 - value(值必须介于 0-255 之间)。

function invertColors(data) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] = data[i] ^ 255; // Invert Red
    data[i+1] = data[i+1] ^ 255; // Invert Green
    data[i+2] = data[i+2] ^ 255; // Invert Blue
  }
}

function onClick() {
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  
  invertColors(imageData.data);

  // Update the canvas with the new data
  context.putImageData(imageData, 0, 0);
}

我们以 4 为增量而不是以 1 为增量来递增循环,这样我们就可以从像素到像素地填充数组中的 4 个元素。

alpha 值对反转颜色没有影响,因此我们跳过它。

亮度和对比度

调整图像的亮度可以使用以下公式完成:newValue = currentValue + 255 * (brightness / 100)

  • brightness 必须介于 -100 和 100 之间
  • currentValue 是红色、绿色或蓝色当前的光值。
  • newValue 是当前颜色光的亮度加上brightness的结果。

调整图像的对比度可以使用此 公式

factor = (259 * (contrast + 255)) / (255 * (259 - contrast))
color = GetPixelColor(x, y)
newRed   = Truncate(factor * (Red(color)   - 128) + 128)
newGreen = Truncate(factor * (Green(color) - 128) + 128)
newBlue  = Truncate(factor * (Blue(color)  - 128) + 128)

主要计算是获得将应用于每个颜色值的对比度因子。Truncate是一个确保值保持在 0 到 255 之间的函数。

让我们将这些函数写入 JavaScript 中

function applyBrightness(data, brightness) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] += 255 * (brightness / 100);
    data[i+1] += 255 * (brightness / 100);
    data[i+2] += 255 * (brightness / 100);
  }
}

function truncateColor(value) {
  if (value < 0) {
    value = 0;
  } else if (value > 255) {
    value = 255;
  }

  return value;
}

function applyContrast(data, contrast) {
  var factor = (259.0 * (contrast + 255.0)) / (255.0 * (259.0 - contrast));

  for (var i = 0; i < data.length; i+= 4) {
    data[i] = truncateColor(factor * (data[i] - 128.0) + 128.0);
    data[i+1] = truncateColor(factor * (data[i+1] - 128.0) + 128.0);
    data[i+2] = truncateColor(factor * (data[i+2] - 128.0) + 128.0);
  }
}

在这种情况下,您不需要truncateColor 函数,因为Uint8ClampedArray 将截断这些值,但为了翻译算法,我们在其中添加了该函数。

需要记住的一件事是,如果您应用了亮度或对比度,则无法恢复到以前的状态,因为图像数据被覆盖了。如果要重置为原始状态,则必须单独存储原始图像数据以供参考。使图像变量可供其他函数访问将很有帮助,因为您可以使用该图像而不是重新绘制具有原始图像的画布。

var image = document.getElementById('SourceImage');

function redrawImage() {
  context.drawImage(image, 0, 0);
}

使用视频

为了使它适用于视频,我们将采用我们的初始图像脚本和 HTML 代码并进行一些小的更改。

HTML

通过用视频元素替换此行来更改 Image 元素

<img id="SourceImage" src="image.jpg">

…用以下内容替换

<video id="SourceVideo" src="video.mp4" width="300" height="150"></video>

JavaScript

替换此行

var image = document.getElementById('SourceImage');

…用以下内容替换

var video = document.getElementById('SourceVideo');

要开始使用视频,我们必须等到视频可以播放。

video.addEventListener('canplay', function () {
    // Set the canvas the same width and height of the video
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;    
    
    // Play the video
    video.play();

    // start drawing the frames  
    drawFrame(video);
});

当有足够的数据可以播放媒体时(至少播放几帧),就会触发事件canplay

我们看不到在画布上显示任何视频,因为我们只显示第一帧。我们必须每隔 n 毫秒执行一次drawFrame 以跟上视频帧速率。

drawFrame 中,我们每隔 10 毫秒调用一次drawFrame

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

执行完 drawFrame 后,我们创建一个循环,每 10 毫秒执行一次 drawFrame - 这足够让视频在画布上保持同步。

将效果添加到视频

我们可以使用之前创建的用于反转颜色的相同函数

function invertColors(data) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] = data[i] ^ 255; // Invert Red
    data[i+1] = data[i+1] ^ 255; // Invert Green
    data[i+2] = data[i+2] ^ 255; // Invert Blue
  }
}

并将其添加到 drawFrame 函数中

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  invertColors(imageData.data);
  context.putImageData(imageData, 0, 0);
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

我们可以添加一个按钮并切换效果

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  if (applyEffect) {
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    invertColors(imageData.data);
    context.putImageData(imageData, 0, 0);
  }
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

使用摄像头

我们将保留与视频相同的代码,唯一的区别是我们将使用 MediaDevices.getUserMedia 将视频流从文件更改为摄像头流

MediaDevices.getUserMedia 是新的 API,它已弃用之前的 API Navigator.getUserMedia。旧版本仍然得到浏览器的支持,而一些浏览器不支持新版本,我们必须使用 polyfill 来确保浏览器支持其中一个

首先,从视频元素中删除 src 属性

<video id="SourceVideo" width="300" height="150"></video>
// Set the source of the video to the camera stream
function initCamera(stream) {
  video.srcObject = stream;
}

if (navigator.mediaDevices.getUserMedia) {
  navigator.mediaDevices.getUserMedia({video: true, audio: false})
    .then(initCamera)
    .catch(console.error)
  );
}

效果

到目前为止,我们所涵盖的一切都是创建视频或图像上不同效果的基础。通过独立地变换每种颜色,我们可以使用许多不同的效果。

灰度

使用不同的公式/技术可以将颜色转换为灰度,为了避免深入研究主题,我将向您展示五个基于 GIMP 去饱和工具亮度 的公式。

Gray = 0.21R + 0.72G + 0.07B // Luminosity
Gray = (R + G + B) ÷ 3 // Average Brightness
Gray = 0.299R + 0.587G + 0.114B // rec601 standard
Gray = 0.2126R + 0.7152G + 0.0722B // ITU-R BT.709 standard
Gray = 0.2627R + 0.6780G + 0.0593B // ITU-R BT.2100 standard

我们希望使用这些公式找到每个像素颜色的亮度强度级别。该值将在 0(黑色)到 255(白色)之间。这些值将创建灰度(黑白)效果。

这意味着最亮的色彩将最接近 255,最暗的色彩将最接近 0。

双色调

双色调效果与灰度效果的区别在于使用的两种颜色。在灰度中,您有从黑色到白色的渐变,而在双色调中,您可以有从任何颜色到任何其他颜色的渐变,例如蓝色到粉色。

使用灰度值的强度值,我们可以将其从渐变值中替换。

我们需要从 ColorAColorB 创建一个渐变。

function createGradient(colorA, colorB) {   
  // Values of the gradient from colorA to colorB
  var gradient = [];
  // the maximum color value is 255
  var maxValue = 255;
  // Convert the hex color values to RGB object
  var from = getRGBColor(colorA);
  var to = getRGBColor(colorB);
  
  // Creates 256 colors from Color A to Color B
  for (var i = 0; i <= maxValue; i++) {
    // IntensityB will go from 0 to 255
    // IntensityA will go from 255 to 0
    // IntensityA will decrease intensity while instensityB will increase
    // What this means is that ColorA will start solid and slowly transform into ColorB
    // If you look at it in other way the transparency of color A will increase and the transparency of color B will decrease
    var intensityB = i;
    var intensityA = maxValue - intensityB;
    
    // The formula below combines the two color based on their intensity
    // (IntensityA * ColorA + IntensityB * ColorB) / maxValue
    gradient[i] = {
      r: (intensityA*from.r + intensityB*to.r) / maxValue,
      g: (intensityA*from.g + intensityB*to.g) / maxValue,
      b: (intensityA*from.b + intensityB*to.b) / maxValue
    };
  }

  return gradient;
}

// Helper function to convert 6digit hex values to a RGB color object
function getRGBColor(hex)
{
  var colorValue;

  if (hex[0] === '#') {
    hex = hex.substr(1);
  }
  
  colorValue = parseInt(hex, 16);
  
  return {
    r: colorValue >> 16,
    g: (colorValue >> 8) & 255,
    b: colorValue & 255
  }
}

简而言之,我们正在创建从 Color A 到 Color B 的颜色值数组,减少 Color A 的强度,同时增加 Color B 的强度。

#0096ff#ff00f0
颜色过渡的缩放表示
var gradients = [
  {r: 32, g: 144, b: 254},
  {r: 41, g: 125, b: 253},
  {r: 65, g: 112, b: 251},
  {r: 91, g: 96, b: 250},
  {r: 118, g: 81, b: 248},
  {r: 145, g: 65, b: 246},
  {r: 172, g: 49, b: 245},
  {r: 197, g: 34, b: 244},
  {r: 220, g: 21, b: 242},
  {r: 241, g: 22, b: 242},
];

上面是 10 个颜色值的渐变示例,从 #0096ff#ff00f0

颜色过渡的灰度表示

现在我们有了图像的灰度表示,我们可以用它来将其映射到双色调渐变值。

双色调渐变有 256 种颜色,而灰度也有 256 种颜色,范围从黑色(0)到白色(255)。这意味着灰度颜色值将映射到渐变元素索引。

var gradientColors = createGradient('#0096ff', '#ff00f0');
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
applyGradient(imageData.data);

for (var i = 0; i < data.length; i += 4) {
  // Get the each channel color value
  var redValue = data[i];
  var greenValue = data[i+1];
  var blueValue = data[i+2];

  // Mapping the color values to the gradient index
  // Replacing the grayscale color value with a color for the duotone gradient
  data[i] = gradientColors[redValue].r;
  data[i+1] = gradientColors[greenValue].g;
  data[i+2] = gradientColors[blueValue].b;
  data[i+3] = 255;
}

结论

这个主题可以更深入地探讨或解释更多效果。您的作业是找到您可以应用于这些骨架示例的不同算法。

了解像素在画布上的结构将使您能够创建无限数量的效果,例如棕褐色、颜色混合、绿屏效果、图像闪烁/故障等。

您甚至可以在不使用图像或视频的情况下动态创建效果: