Canvas 实现 PS 中的正片叠底效果

996 阅读7分钟

前言

年前在知乎和抖音上刷到了一些关于“正片叠底”的讲解,感觉挺有意思的,于是趁现在有时间决定用 Canvas 来实现一下这个效果。

在图像和视频编辑中,“正片叠底”是一种常见的混合模式,能够创造出丰富的视觉效果。如果你用过 Photoshop 或其他设计工具,应该对这个功能不陌生。本文将带你从零开始,使用 Canvas 实现正片叠底效果,并且会分别对图片和视频进行实现。

什么是正片叠底?

简单来说,正片叠底是一种混合模式,通过将两张图片或两个视频的像素逐一叠加,计算它们的颜色值。最终的效果通常会让画面变得更暗,颜色更加浓郁。

它的核心公式是:

R=A×B255R=\frac{A \times B}{255}

其中:

  • A:底层像素的颜色值。
  • B:上层像素的颜色值。
  • R:输出像素的颜色值。

这个公式模拟了摄影中的光学效果,将两层画面的明暗部分巧妙地结合在一起。

抖音上有一个短视频讲解得非常清楚,链接放在文末,感兴趣的话可以看看。

正片叠底的原理

从数学角度来看,正片叠底通过对每个像素的 RGB 值进行相乘并归一化,减少了高亮区域的影响,因此整体效果会偏暗。

举个例子,假设有两个像素:

  • 底层像素:A=(100,150,200)
  • 上层像素:B=(50,100,150)

通过公式计算:

R=(100×50255,150×100255,200×150255)(20,59,118)R = \left( \frac{100 \times 50}{255}, \frac{150 \times 100}{255}, \frac{200 \times 150}{255} \right) \approx (20, 59, 118)

最终输出的像素值 R 是一个较暗的颜色。

正片叠底的实现

在 Canvas 中,我们可以通过操作像素数据来实现正片叠底效果。主要步骤如下:

  1. 使用 getContext('2d') 获取 Canvas 的上下文。
  2. 绘制两张图片到 Canvas 上。
  3. 通过 getImageData 获取两层的像素数据。
  4. 对像素数据进行遍历,根据正片叠底公式计算新像素值。
  5. 将计算后的像素数据渲染回 Canvas。

接下来,我们用代码来实现!

实现图片的正片叠底效果

创建一个黑底白字的图片

首先,我们需要创建一个黑底白字的图片,作为上层的图片。

/**
 * 创建一个带有文本内容的画布。
 * @param {number} width - 画布的宽度。
 * @param {number} height - 画布的高度。
 * @returns {Object} - 包含画布和上下文的对象。
 */
function createTextImageCanvas(width, height) {
  const textCanvas = document.createElement('canvas');
  textCanvas.width = width;
  textCanvas.height = height;

  const textContext = textCanvas.getContext('2d');
  textContext.fillStyle = 'black';
  textContext.fillRect(0, 0, width, height);

  textContext.font = '130px Arial Black';
  textContext.fillStyle = 'white';
  textContext.textAlign = 'center';
  textContext.textBaseline = 'middle';
  textContext.fillText('Hello, World', width / 2, height / 2);

  return { canvas: textCanvas, context: textContext };
}

const textImageCanvas = createTextImageCanvas(1000, 600);
document.body.appendChild(textImageCanvas.canvas);

实现效果:

加载图片

接下来,我们使用 Canvas 加载一张图片,这张图片将作为底层图片。

/**
  * 绘制叠加后的图像到主画布。
  * @param {string} imagePath - 底层图片路径。
  */
function drawBlendedImage(imagePath) {
  const canvas = document.getElementById('canvas');
  const context = canvas.getContext('2d');
  canvas.width = 1000; // 设置画布宽度
  canvas.height = 700; // 设置画布高度

  // 创建带有文本的画布
  const textCanvas = createTextImageCanvas(canvas.width, canvas.height);

  const baseImage = new Image();
  baseImage.src = imagePath;

  // 当图片加载完成时,开始处理图像
  baseImage.onload = () => {
    context.drawImage(baseImage, 0, 0, canvas.width, canvas.height); // 绘制底层图片
  };
}

drawBlendedImage('./img/img.jpg')

实现效果:

正片叠底算法

现在,我们已经有了两张图片,接下来需要将文字图片和背景图片融合到一起,使文字部分镂空显示背景图片。我们需要实现一个正片叠底混合算法。

通过 ctx.getImageData 获取两张图片的像素数据,然后对每个像素点进行 A×B255\frac{A \times B}{255} 的计算,最后将结果通过 ctx.putImageData 渲染回 Canvas。

/**
 * 应用正片叠底混合效果。
 * @param {ImageData} baseImage - 底层图像的像素数据。
 * @param {ImageData} overlayImage - 叠加图像的像素数据。
 * @returns {ImageData} - 混合后的像素数据。
 */
function applyMultiplyBlend(baseImage, overlayImage) {
  const baseData = baseImage.data;
  const overlayData = overlayImage.data;
  const resultImage = new ImageData(baseImage.width, baseImage.height);
  const resultData = resultImage.data;

  for (let i = 0; i < baseData.length; i += 4) {
    resultData[i] = (baseData[i] * overlayData[i]) / 255; // 混合红色通道
    resultData[i + 1] = (baseData[i + 1] * overlayData[i + 1]) / 255; // 混合绿色通道
    resultData[i + 2] = (baseData[i + 2] * overlayData[i + 2]) / 255; // 混合蓝色通道
    resultData[i + 3] = baseData[i + 3]; // 保留透明度
  }

  return resultImage;
}

实现效果:

完整代码

以下是两张图片进行正片叠底混合的完整代码示例:

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>正片叠底效果示例</title>
</head>

<body>
  <canvas id="canvas"></canvas>

  <script>
    /**
      * 创建一个带有文本内容的画布。
      * @param {number} width - 画布的宽度。
      * @param {number} height - 画布的高度。
      * @returns {Object} - 包含画布和上下文的对象。
      */
    function createTextImageCanvas(width, height) {
      const textCanvas = document.createElement("canvas");
      textCanvas.width = width;
      textCanvas.height = height;

      const textContext = textCanvas.getContext("2d");
      textContext.fillStyle = "black";
      textContext.fillRect(0, 0, width, height);

      textContext.font = "130px Arial Black";
      textContext.fillStyle = "white";
      textContext.textAlign = "center";
      textContext.textBaseline = "middle";
      textContext.fillText("Hello, World", width / 2, height / 2);

      return { canvas: textCanvas, context: textContext };
    }

    /**
     * 应用正片叠底混合效果。
     * @param {ImageData} baseImage - 底层图像的像素数据。
     * @param {ImageData} overlayImage - 叠加图像的像素数据。
     * @returns {ImageData} - 混合后的像素数据。
     */
    function applyMultiplyBlend(baseImage, overlayImage) {
      const baseData = baseImage.data;
      const overlayData = overlayImage.data;
      const resultImage = new ImageData(baseImage.width, baseImage.height);
      const resultData = resultImage.data;

      for (let i = 0; i < baseData.length; i += 4) {
        resultData[i] = (baseData[i] * overlayData[i]) / 255; // 混合红色通道
        resultData[i + 1] = (baseData[i + 1] * overlayData[i + 1]) / 255; // 混合绿色通道
        resultData[i + 2] = (baseData[i + 2] * overlayData[i + 2]) / 255; // 混合蓝色通道
        resultData[i + 3] = baseData[i + 3]; // 保留透明度
      }

      return resultImage;
    }

    /**
     * 绘制叠加后的图像到主画布。
     * @param {string} imagePath - 底层图片路径。
     */
    function drawBlendedImage(imagePath) {
      const canvas = document.getElementById('canvas');
      const context = canvas.getContext('2d');
      canvas.width = 1000; // 设置画布宽度
      canvas.height = 700; // 设置画布高度

      // 创建带有文本的画布
      const textCanvas = createTextImageCanvas(canvas.width, canvas.height);

      const baseImage = new Image();
      baseImage.src = imagePath;

      // 当图片加载完成时,开始处理图像
      baseImage.onload = () => {
        context.drawImage(baseImage, 0, 0, canvas.width, canvas.height); // 绘制底层图片

        // 获取底层图像和叠加文本的像素数据
        const baseImageData = context.getImageData(0, 0, canvas.width, canvas.height);
        const overlayImageData = textCanvas.context.getImageData(0, 0, textCanvas.canvas.width, textCanvas.canvas.height);

        // 应用正片叠底混合效果
        const blendedImageData = applyMultiplyBlend(baseImageData, overlayImageData);

        // 将混合后的图像数据绘制回画布
        context.putImageData(blendedImageData, 0, 0);
      };
    }

    // 调用函数绘制图像(替换为实际图片路径)
    drawBlendedImage('./img/tk.jpg');
  </script>
</body>

</html>

实现视频的正片叠底效果

刚才我们实现了图片的正片叠底效果,接下来我们结合视频来实现一个炫酷一些的效果。

具体实现

处理视频的正片叠底与处理图片的基本思路相同,唯一的区别是,我们需要通过 requestAnimationFrame 获取视频的每一帧,然后结合上层图片进行正片叠底计算。

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>视频与图片正片叠底效果</title>
  <style>
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
    }

    #blend-container {
      position: relative;
      width: 100%;
      height: 100%;
    }

    video,
    canvas {
      position: absolute;
      top: 0;
      left: 0;
    }

    video {
      visibility: hidden;
    }
  </style>
</head>

<body>
  <div id="blend-container">
    <video id="source-video" autoplay loop muted playsinline></video>
    <canvas id="blend-canvas"></canvas>
  </div>
  <script>
    const videoElement = document.getElementById("source-video");
    const canvasElement = document.getElementById("blend-canvas");
    const canvasContext = canvasElement.getContext("2d");

    /**
     * 创建一个带有文本内容的画布。
     * @param {number} width - 画布的宽度。
     * @param {number} height - 画布的高度。
     * @returns {Object} - 包含画布和上下文的对象。
     */
    function createTextImageCanvas(width, height) {
      const textCanvas = document.createElement("canvas");
      textCanvas.width = width;
      textCanvas.height = height;

      const textContext = textCanvas.getContext("2d");
      textContext.fillStyle = "black";
      textContext.fillRect(0, 0, width, height);

      textContext.font = "150px Arial Black";
      textContext.fillStyle = "white";
      textContext.textAlign = "center";
      textContext.textBaseline = "middle";
      textContext.fillText("Hello, World", width / 2, height / 2);

      return { canvas: textCanvas, context: textContext };
    }

    /**
     * 对两个图像帧应用正片叠底混合模式。
     * @param {ImageData} videoFrame - 视频帧的图像数据。
     * @param {ImageData} imageFrame - 图像帧的图像数据。
     * @returns {ImageData} - 混合后的图像数据。
     */
    function applyMultiplyBlend(videoFrame, imageFrame) {
      const videoData = videoFrame.data;
      const imageData = imageFrame.data;
      const blendedFrame = new ImageData(videoFrame.width, videoFrame.height);

      for (let i = 0; i < videoData.length; i += 4) {
        blendedFrame.data[i] = (videoData[i] * imageData[i]) / 255; // 红色通道
        blendedFrame.data[i + 1] = (videoData[i + 1] * imageData[i + 1]) / 255; // 绿色通道
        blendedFrame.data[i + 2] = (videoData[i + 2] * imageData[i + 2]) / 255; // 蓝色通道
        blendedFrame.data[i + 3] = videoData[i + 3]; // 透明通道
      }

      return blendedFrame;
    }

    /**
     * 在画布上绘制混合效果。
     */
    function drawBlendEffect() {
      if (videoElement.readyState >= 2) {
        // 绘制当前视频帧
        canvasContext.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
        const videoFrame = canvasContext.getImageData(0, 0, canvasElement.width, canvasElement.height);

        // 创建并绘制文本图像帧
        const textImage = createTextImageCanvas(canvasElement.width, canvasElement.height);
        const imageFrame = textImage.context.getImageData(0, 0, textImage.canvas.width, textImage.canvas.height);

        // 应用正片叠底混合模式并渲染
        const blendedFrame = applyMultiplyBlend(videoFrame, imageFrame);
        canvasContext.putImageData(blendedFrame, 0, 0);
      }

      requestAnimationFrame(drawBlendEffect);
    }

    // 初始化视频和画布设置
    videoElement.src = "./img/video.mp4";
    videoElement.addEventListener("loadeddata", () => {
      canvasElement.width = videoElement.videoWidth;
      canvasElement.height = videoElement.videoHeight;
    });
    videoElement.addEventListener("play", drawBlendEffect);
  </script>
</body>

</html>

实现效果

结语

通过本文,我们了解了正片叠底的原理,并使用 Canvas 实现了图片和视频的正片叠底效果。这种技术不仅可以用于学习图像处理的基础知识,还可以帮助你在前端开发中实现一些酷炫的效果。

相关链接