javascriptcanvasaspect-ratiodrawimagelinear-interpolation

Canvas drawImage: Proportion Issue when linearly interpolating from "fit"- to "fill"-style settings


I have a canvas and I want to be able to draw an image in different sizes from "fit" (like CSS "contain") to "fill" (like CSS "cover"). I use drawImage() with different source and destination properties for fit and fill. Both extremes work perfectly as expected, but in between the image proportions are way off, and the image looks flat. I use linear interpolation to calculate the between source and destination properties.

I can't get my head around why this is happening.

"fit/contain" properties:

ctx.drawImage(
    img, // image
    0, // source x
    0, // source y
    img.width, // source width
    img.height, // source height
    (canvas.width - canvas.height * imageAspect) / 2, // destination x
    0, // destination y
    canvas.height * imageAspect, // destination width
    canvas.height // destination height
)

"fill/cover" Properties:

ctx.drawImage(
    img, // image
    0, // source x
    (image.height - img.width / canvasAspect) / 2, // source y
    img.width, // source width
    img.width / canvasAspect, // source height
    0, // destination x
    0, // destination y
    canvas.width, // destination width
    canvas.height // destination height
)

These are both fine, but linear interpolation of all the values get the wrong proportions of the image. Here's a quick demo that is not working as expected, I animated the interpolation so that you can see the squished effect more clearly:

Code Pen

The desired result would be keeping the image's proportions right in every step between 0 (fit) and 1 (fill). What am I missing here?

EDIT: The easiest solution would be to always take the full source image (not crop it with sX, sY, sWidth, and sHeight) and then draw the destination with negative coordinate values on the canvas when the image is bigger than the canvas. This is working but it is not the desired behavior. Because further on I need to be able to draw only to a certain sub-rectangle in the canvas, where the overlapping ("negative values") would be seen. I don't want to draw outside the rectangle. I am quite sure it is just a small mathematical issue here that needs to be solved.


Solution

  • For me, the solution in your "Edit" is the way to go.
    If later on you want to clip the image in a smaller rectangle than the canvas, use the clip() method:

    const canvas = document.querySelector("canvas");
    const ctx = canvas.getContext("2d");
    canvas.width = 800;
    canvas.height = 150;
    let step = 1;
    let direction = 1;
    
    // control the clipping rect position with the mouse
    const mouse = {x: 400, y: 75};
    onmousemove = (evt) => {
      const rect = canvas.getBoundingClientRect();
      mouse.x = evt.clientX - rect.left;
      mouse.y = evt.clientY - rect.top;
    };
    
    function getBetweenValue(from, to, stop) {
      return from + (to - from) * stop;
    }
    
    const image = new Image();
    image.src =
      "https://w7.pngwing.com/pngs/660/154/png-transparent-perspective-grid-geometry-grid-perspective-grid-geometric-grid-grid.png";
    
    let imageAspect = 0;
    let canvasAspect = canvas.width / canvas.height;
    let source;
    let containDestination;
    let coverDestination;
    
    function draw(image) {
      ctx.save();
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // Clip the context in a sub-rectangle
      ctx.beginPath();
      ctx.rect(mouse.x - 150, mouse.y - 50, 300, 100);
      ctx.stroke();
      ctx.clip();
      // Since our image scales from the middle of the canvas,
      // set the context's origin there, that makes our BBox values simpler
      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.drawImage(
        image,
        source.x,
        source.y,
        source.width,
        source.height,
        getBetweenValue(containDestination.x, coverDestination.x, step),
        getBetweenValue(containDestination.y, coverDestination.y, step),
        getBetweenValue(containDestination.width, coverDestination.width, step),
        getBetweenValue(containDestination.height, coverDestination.height, step)
      );
      ctx.restore(); // remove clip & transform
    }
    image.addEventListener("load", () => {
      imageAspect = image.width / image.height;
      source = {
        x: 0,
        y: 0,
        width: image.width,
        height: image.height
      };
      containDestination = {
        x: -(canvas.height * imageAspect) / 2,
        y: -(canvas.height / 2),
        width: canvas.height * imageAspect,
        height: canvas.height
      };
      coverDestination = {
        x: -image.width / 2,
        y: -image.height / 2,
        width: image.width,
        height: image.height
      };
      raf();
    });
    
    
    function raf() {
      draw(image);
      step += .005 * direction;
      if (step > 1 || step < 0) {
        direction *= -1;
      }
      window.requestAnimationFrame(raf);
    }
    canvas {
      border:1px solid red;
    }
    img {
      max-width:30em;
      height:auto;
    }
    Use your mouse to move the clipping rectangle<br>
    <canvas></canvas><br><br>
    Original image proportions:<br>
    <img src="https://w7.pngwing.com/pngs/660/154/png-transparent-perspective-grid-geometry-grid-perspective-grid-geometric-grid-grid.png" alt="">