javascriptcanvascolorscss-filtersglobalcompositeoperation

Add color filter to dark part of image and another filter to light part of the image?


My challenge is to add color filter to dark part of image and another color filter to light part of image. To achieve effect like this https://i.sstatic.net/niAKW.jpg

I am using canvas with globalCompositeOperation effects, but I am able to apply only one filter without affect the other one.

ctx.drawImage(image, 0, 0, 380, 540);
ctx.globalCompositeOperation = 'darken';
ctx.fillStyle = overlayFillColor;
ctx.fillRect(0, 0, 380, 540);

this works great to apply color filter to dark or light areas, based on the globalCompositeOperation, but if I add another filter, it change colors of the previous filter as well.

any idea?

thanks Ales


Solution

  • There is a nice SVG filter component which does map luminance to alpha: <feColorMatrix type="luminanceToAlpha"/>
    Since we can use SVG filters in canvas, this allows us to separate the dark area from the light one and use compositing instead of blending.

    This way, your input colors are preserved.

    (async () => {
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext("2d");
      const img = new Image();
      img.src = "https://picsum.photos/500/500";
      await img.decode();
      canvas.width = img.width;
      canvas.height = img.height;
      // first we create our alpha layer
      ctx.filter = "url(#lumToAlpha)";
      ctx.drawImage(img, 0, 0);
      ctx.filter = "none";
      const alpha = await createImageBitmap(canvas);
      
      // draw on "light" zone
      ctx.globalCompositeOperation = "source-in";
      ctx.fillStyle = "red";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      // save into an ImageBitmap
      // (note that we could also use a second canvas to do this all synchronously)
      const light = await createImageBitmap(canvas);
      
      // clean canvas
      ctx.globalCompositeOperation = "source-over";
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // draw on "dark" zone
      ctx.drawImage(alpha, 0, 0);
      ctx.globalCompositeOperation = "source-out";
      ctx.fillStyle = "blue";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      // reintroduce "light" zone
      ctx.globalCompositeOperation = "source-over";
      ctx.drawImage(light, 0, 0);
    })().catch(console.error);
    <svg width="0" height="0" style="visibility:hidden;position:absolute">
      <filter id="lumToAlpha">
        <feColorMatrix type="luminanceToAlpha" />
      </filter>
    </svg>
    <canvas></canvas>
    <!--
      If you don't like having an element in the DOM just for that
      you could also directly set the context's filter to a data:// URI
      url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cfilter%20id%3D%22f%22%3E%3CfeColorMatrix%20type%3D%22luminanceToAlpha%22%2F%3E%3C%2Ffilter%3E%3C%2Fsvg%3E#f");
      but you'd have to wait a least a task (setTimeout(fn, 0))
      because setting filters this way is async...
      Hopefully CanvasFilters will solve this soon enough
    -->

    Note that hopefully we'll have CanvasFilters objects in a near future, which will make SVG filters to canvas easier to use, and accessible in Workers (they currently aren't...). So for the ones from the future (or from the present on Canary with web-features flag on), this would look like:

    // typeof CanvasFilter === "function"
    // should be enough for detecting colorMatrix
    // but see below for how to "correctly" feature-detect
    // a particular CanvasFilter
    if (supportsColorMatrixCanvasFilter()) {
    (async () => {
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext("2d");
      const img = new Image();
      img.src = "https://picsum.photos/500/500";
      await img.decode();
      canvas.width = img.width;
      canvas.height = img.height;
      // first we create our alpha layer
      ctx.filter = new CanvasFilter({
        filter: "colorMatrix",
        type: "luminanceToAlpha"
      });
      ctx.drawImage(img, 0, 0);
      ctx.filter = "none";
      const alpha = await createImageBitmap(canvas);
      
      // draw on "light" zone
      ctx.globalCompositeOperation = "source-in";
      ctx.fillStyle = "red";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      // save into an ImageBitmap
      // (note that we could also use a second canvas to do this all synchronously)
      const light = await createImageBitmap(canvas);
      
      // clean canvas
      ctx.globalCompositeOperation = "source-over";
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // draw on "dark" zone
      ctx.drawImage(alpha, 0, 0);
      ctx.globalCompositeOperation = "source-out";
      ctx.fillStyle = "blue";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      // reintroduce "light" zone
      ctx.globalCompositeOperation = "source-over";
      ctx.drawImage(light, 0, 0);
    })().catch(console.error);
    }
    else {
      console.error("your browser doesn't support CanvasFilters yet");
    }
    // Feature detection is hard...
    // see https://gist.github.com/Kaiido/45d189c110d29ac2eda25a7762c470f2
    // to get the list of all supported CanvasFilters
    // below only checks for colorMatrix
    function supportsColorMatrixCanvasFilter() {
      if(typeof CanvasFilter !== "function") {
        return false;
      }
      let supported = false;
      try {
        new CanvasFilter({
          filter: "colorMatrix",
          // "type" will be visited for colorMatrix
          // we throw in to avoid actually creating the filter
          get type() { supported = true; throw ""; }
        });
      } catch(err) {}
      return supported;
    }
    <canvas></canvas>