Search code examples
javascriptcanvasmatrixwebglperspective

WebGL glfx.js matrix transform (perspective) crops the image if it rotates


I am using the glfx.js library in order to use matrix transformation to create the perspective effect for my images. In my app, the system works just like photoshop's smart objects (where you render a flat image and get perspective results after render)

glfx.js uses this function canvas.perspective(before, after) to apply matrix transforms to images, by assigning before and after coordination of the 4 points in an image, and it runs the Matrix command in the background to transform my image.

My issue is that if the resulting image that I want after the transformation applied to it is bigger than the original image (happens if you rotate the image) then the WebGL canvas is going to crop my image.

Look at the following fiddle:

https://jsfiddle.net/human_a/o4yrheeq/

window.onload = function() {
    try {
      var canvas = fx.canvas();
    } catch (e) {
      alert(e);
      return;
    }

    // convert the image to a texture
    var image = document.getElementById('image');
    var texture = canvas.texture(image);

    // apply the perspective filter
    canvas.draw(texture).perspective( [0,0,774,0,0,1094,774,1094], [0,389,537,0,732,1034,1269,557] ).update();

    image.src = canvas.toDataURL('image/png');

    // or even if you replace the image with the canvas
    // image.parentNode.insertBefore(canvas, image);
    // image.parentNode.removeChild(image);
};
<script src="https://evanw.github.io/glfx.js/glfx.js"></script>
<img id="image" crossOrigin="anonymous" src="https://images.unsplash.com/photo-1485207801406-48c5ac7286b2?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=600&fit=max&s=9bb1a18da78ab0980d5e7870a236af88">

Any ideas on how we can make the WebGL canvas fit the rotated image (not make the image smaller) or somehow extract the whole image instead of the cropped one?


Solution

  • ##More pixels

    There is no cover all solution. This is because when you convert from 2D to 3D the size of the projected image can possibly approch infinity (near clipping prevents infinity) so no matter how large you make the image output there is always the possibility of some clipping being applied.

    With that caveat out of the way there is a solution for most situations that can avoid clipping. It is very simple, just expand the canvas to hold the additional content.

    ###Find the bounds

    To simplify the calculations I have changed the after array to a set of normalised points (they represent the after coords as a scale factor of the image size). I then use the image size to convert to real pixel coordinates. Then from that I workout the min size a texture needs to be to hold both the original image and the projection.

    With that info I just create the texture (as a canvas) draw the image. Adjust the befor array if needed (in case some projection points are in negative space) and apply the filter.

    So we have an image object that has a width and a height. And you have the projection of those points.

    // assuming image has been loaded and is ready
    var imgW = image.naturalWidth;
    var imgH = image.naturalHeight;
    

    The set the corner array (before)

    var before = [0, 0, imgW, 0, 0, imgH, imgW, imgH];
    

    The projection points. To make it easier to deal with I have normalised the projection points to the image size

    var projectNorm =  [[0, 0.3556], [0.6938, 0], [0.9457, 0.9452], [1.6395, 0.5091]];
    

    If you want to use the absolute coordinates as in the fiddle's after array use the following code. The normalisation is reversed in the snippet after then next, so you can skip the normalisation. I have just updated the answer quickly as I am short of time.

    var i = 0;
    var afterArray = [0,389,537,0,732,1034,1269,557];
    const projectNorm = [];
    while (i < afterArray.length){
         projectNorm.push([
             before[i] ? afterArray[i] / before[i++] : before[i++],
             before[i] ? afterArray[i] / before[i++] : before[i++]
         ]);
    }
    

    Now calculate the size of the projection. This is the important part as it works out the size of the canvas.

    var top, left, right, bottom;
    top = 0;
    left = 0;
    bottom = imgH;
    right = imgW;
    var project = projectNorm.map(p => [p[0] * imgW, p[1] * imgH]);
    project.forEach(p => {
        top = Math.min(p[1], top);
        left = Math.min(p[0], left);
        bottom = Math.max(p[1], bottom);
        right = Math.max(p[0], right);
    });
    

    Now that all the data we need has been gathered we can create a new image that will accommodate the projection. (assuming that the projection points are true to the projection)

    var texture = document.createElement("canvas");
    var ctx = texture.getContext("2d");
    texture.width = Math.ceil(right - left);
    texture.height = Math.ceil(bottom - top);
    

    Draw the image at 0,0

    ctx.setTransform(1, 0, 0, 1, left, top); // put origin so image is at 0,0
    ctx.drawImage(image,0,0);
    ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
    

    Then flatten the projection point array

    var after = [];
    project.forEach(p => after.push(...p));
    

    Move all points into positive projection space

    after.forEach((p,i) => {
        if (i % 2) {
            before[i] += -top;
            after[i] += -top;
        } else {
            before[i] += -left;
            after[i] += -left;
        }
    });
    

    The final step is to create the glfx.js objects and apply the filter

    // create a fx canvas
    var canvas = fx.canvas();
    // create the texture 
    var glfxTexture = canvas.texture(texture);
    // apply the filter
    canvas.draw(glfxTexture).perspective( before, after ).update();
    // show the result on the page
    document.body.appendChild(canvas);
    

    ###Demo

    Demo of your snippet using the above method (slight modification for image load)

    // To save time typing I have just kludged a simple load image wait poll
    waitForLoaded();
    function waitForLoaded(){
        if(image.complete){
            projectImage(image);
        }else{
            setTimeout(waitForLoaded,500);
        }
    }
    function projectImage(image){
        var imgW = image.naturalWidth;
        var imgH = image.naturalHeight;
        var projectNorm =  [[0, 0.3556], [0.6938, 0], [0.9457, 0.9452], [1.6395, 0.5091]];
        var before = [0, 0, imgW, 0, 0, imgH, imgW, imgH];
        var top, left, right, bottom;
        top = 0;
        left = 0;
        bottom = imgH;
        right = imgW;
        var project = projectNorm.map(p => [p[0] * imgW, p[1] * imgH]);
        project.forEach(p => {
            top = Math.min(p[1], top);
            left = Math.min(p[0], left);
            bottom = Math.max(p[1], bottom);
            right = Math.max(p[0], right);
        });
        var texture = document.createElement("canvas");
        var ctx = texture.getContext("2d");
        texture.width = Math.ceil(right - left);
        texture.height = Math.ceil(bottom - top);
        ctx.setTransform(1, 0, 0, 1, left, top); // put origin so image is at 0,0
        ctx.drawImage(image,0,0);
        ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
        var after = [];
        project.forEach(p => after.push(...p));
    
        after.forEach((p,i) => {
            if (i % 2) {
               before[i] += -top;
               after[i] += -top;
            } else {
                before[i] += -left;
                after[i] += -left;
            }
        });
        // create a fx canvas
        var canvas = fx.canvas();
        // create the texture 
        var glfxTexture = canvas.texture(texture);
        // apply the filter
        canvas.draw(glfxTexture).perspective( before, after ).update();
        // show the result on the page
        document.body.appendChild(canvas);
    }    
    #image {
      display : none;
    }
    <script src="https://evanw.github.io/glfx.js/glfx.js"></script>
    <img id="image" crossOrigin="anonymous" src="https://images.unsplash.com/photo-1485207801406-48c5ac7286b2?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=1080&fit=max&s=9bb1a18da78ab0980d5e7870a236af88">

    ###Notes and a warning

    Note that the projection points (after array) do not always match the final corner points of the projected image. If this happens the final image may be clipped.

    Note This method only works if the before points represent the exterme corners of the original image. If the points (before) are inside the image then this method may fail.

    Warning There is no vetting of the resulting image size. Large Images can cause the browser to become sluggish, and sometimes crash. For production code you should do your best to keep the image size within the limits of the device that is using your code. Clients seldom return to pages that are slow and/or crash.