Clip part of canvas to another canvas

Viewed 180

So I have this piece of code that I use for erasing and restoring parts of an image with a (for example) removed background. Erasing from the main canvas is simple and the user can erase a circular shape with a line between points.

if(removeMode) {
    ctxs[index].globalCompositeOperation = 'destination-out';
    ctxs[index].beginPath();
    ctxs[index].arc(x, y, radius, 0, 2 * Math.PI);
    ctxs[index].fill();

    ctxs[index].lineWidth = 2 * radius;
    ctxs[index].beginPath();
    ctxs[index].moveTo(old.x, old.y);
    ctxs[index].lineTo(x, y);
    ctxs[index].stroke();
}

The problem is with the restoring. Currently I am able to copy parts of the original image to the main canvas but only in a rectangular shape using the getImageData() and putImageData() functions.

ctxs[index].globalCompositeOperation = 'source-out';
ctxs[0].putImageData(ctxs[1].getImageData(x-radius, y-radius, 2*radius, 2*radius), x-radius, y-radius);

Ideally I would like to clip a part of the original image canvas to the main canvas with a shape similar to the erasing feature. I have tried the clip() function but honestly I am not sure how to go about it. Here is what I initially tried to clip a part of a canvas.

ctxs[index].beginPath();
ctxs[index].arc(x, y, radius, 0, Math.PI * 2);
ctxs[index].fill();

ctxs[index].lineWidth = 2 * radius;
ctxs[index].beginPath();
ctxs[index].moveTo(old.x, old.y);
ctxs[index].lineTo(x, y);
ctxs[index].stroke();

ctxs[index].clip();

How do I copy a custom shape from a canvas to another canvas? Thanks in advance,

Edit:

I have also thought of using a mask where I would create the mask as such (example using numpy in python):

Y, X = np.ogrid[:canvas_height, :canvas_width]
# Y, X are matrix values and x, y are coordinates of the cursor within the image
center_dist = np.sqrt((X - x)**2 + (Y-y)**2)

# create mask
mask = center_dist <= radius

# omit everything except circular shape from mask
circular_img = original_img.copy()
circular_img[~mask] = 0 

# combine images
new_img = np.maximum(original_img, new_img)

Example of what I have now

2 Answers

Simpler solution

Every shape fits into a rectangle.

Proof Your canvas is a rectangle and already contains the shape.

As a result, you can determine the smallest possible rectangle that contains the full shape and store that. It will necessarily contain your shape. Upon reload you will need to know the shape's boundaries inside the copy though, so that info will also be needed.

Harder, but more precise solution

You can create a structure and store the content, point-by-point (yet, this will be not very performant):

const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
let myShape = [];
for (let x = 0; x < canvas.width; x++) {
    for (let y = 0; y < canvas.height; y++) {
        if (inShape(x, y, canvas)) {
            myShape.push({x, y, content: data});
        }
    }
}

The snippet above assumes that you have properly implemented inShape.

Homogeneous shape

If all the points inside the shape are similar, then you will need to only know where the boundaries of the shape were. If you have a convex polygon, for example, then you will need to know where its center is and what the boundaries are. If you have a filled circle, then you will only need its center and radius. The geometrical data you need largely depend on what shape you have.

Keep using composite operations.

"destination-out" will indeed remove the previous pixels that do overlap with the new ones.

If you use the inverse "destination-in", only the previous pixels that do overlap with the new ones are kept.

So you store your original image intact, and then use one of these modes to render it given the action you want to perform.
Here since it seems we are in a paint-like configuration, I guess it makes more sense to erase the final result and restore the original canvas. For this we need a third canvas, detached where we'll draw the "restoration" part on its own before drawing that back to the visible canvas:

(async () => {
  // the main, visible canvas
  const canvas = document.querySelector("canvas");
  canvas.width = 500;
  canvas.height = 250;
  const ctx = canvas.getContext("2d");
  // a detached canvas context to do the compositing
  const detached = canvas.cloneNode().getContext("2d");
  // the "source" canvas (here just an ImageBitmap)
  const originalCanvas = await loadImage();
  
  ctx.lineWidth = detached.lineWidth = 8;
  
  // we store every drawing in its own Path2D object
  const paths = [];
  let down = false;
  const checkbox = document.querySelector("input");
  canvas.onmousedown = (evt) => {
    down = true;
    const newPath = new Path2D();
    newPath.isEraser = !checkbox.checked;
    paths.push(newPath);
  };
  canvas.onmouseup = (evt) => { down = false; };
  canvas.onmousemove = (evt) => {
    if (!down) { return; }
    const {x, y} = parseMouseEvent(evt);
    paths[paths.length - 1].lineTo(x, y);
    redraw();
  };
  redraw();
  function redraw() {
    // clear the visible context
    ctx.globalCompositeOperation = "source-over";
    ctx.drawImage(originalCanvas, 0, 0);
    paths.forEach((path) => {
      if (path.isEraser) {
        // erase the current content
        ctx.globalCompositeOperation = "destination-out";
        ctx.stroke(path);
      }
      else {
        // to restore
        // we do the compositing on the detached canvas
        detached.globalCompositeOperation = "source-over";
        detached.drawImage(originalCanvas, 0, 0);
        detached.globalCompositeOperation = "destination-in";
        detached.stroke(path);
        // draw the result on the main context
        ctx.globalCompositeOperation = "source-over";
        ctx.drawImage(detached.canvas, 0, 0);
      }
    });
  }
})().catch(console.error);

async function loadImage() {
  const url = "https://picsum.photos/500/250";
  const req = await fetch(url);
  const blob = req.ok && await req.blob();
  return createImageBitmap(blob);
}
function parseMouseEvent(evt) {
  const rect = evt.target.getBoundingClientRect();
  return {x: evt.clientX - rect.left, y: evt.clientY - rect.top };
}
canvas { border: 1px solid; vertical-align: top }
<label>erase/restore <input type="checkbox"></label>
<canvas></canvas>

Note that here I do create new paths every time, but you could very well use the same ones for both erasing and restoring (and even any other graphic source).

Related