Selecting an area of a perspective image to de-skew inside AutoCAD using HTML5 and JavaScript

So after several posts leading up to the big reveal, as it were, in today's post we're going to see the full "De-skew Raster" application in action – and give you the complete source to fool around with.

The main addition over where we were in the last post is the HTML5 and JavaScript UI implementation, as well as the new C# command – called DESKEW – that loads and displays it:

Selecting the area of the whiteboard to de-skew

Our JavaScript code uses the new JavaScript API in AutoCAD 2014 to execute the other command (DESKEW_IMAGE, which we saw implemented last time) that drives the core Python implementation.

I had a lot of fun creating this UI. I started with an HTML5 sample I found that implements an image cropping tool, but adjusted the code to maintain four separate vertices for the corners (rather than forcing the selection area to be aligned with the image itself). I also implemented a magnifying area to help with more precise selection of the respective vertices.

Here's the HTML file, which is extremely simple:

<html>

  <head>

    <title>De-skew perspective image</title>

    <link href="main.css" rel="stylesheet" type="text/css" />

    <script

      type="text/javascript"

      src="http://code.jquery.com/jquery-latest.min.js">

    </script>

    <script

      type="text/javascript"

      src="http://www.autocadws.com/jsapi/v1/Autodesk.AutoCAD.js">

    </script>

    <script src="WindowSelect.js" type="text/javascript"></script>

  </head>

  <body onload="onLoad()">

    <div class="container">

      <div class="contr">

        <div class="inside">

          <div style="float:left">

            <button onclick="deskewCmd()">

              De-skew selected area

            </button>

          </div>

          <div

            class="facinput"

            style="float:right"

            title="A value of 1 means a square output image">

            Width over height:

            <input

              class="numinput"

              id="factor"

              type="number"

              value="1.0" />

          </div>

        </div>

      </div>

      <div id="canvases">

        <div style="float:left"><canvas id="panel"/></div>

        <div style="float:left"><canvas id="magnifier"/></div>

      </div>

    </div>

  </body>

</html>

The JavaScript behind it has a little more going on, but still:

// Based on http://www.script-tutorials.com/demos/197/index.html

 

// Globals

 

var canvas, ctx, magnifier, magctx;

var image;

var mouseX, mouseY = 1;

var selArea;

var urlVars;

var off = 10;

var zs = 10;

var magsz = off * zs

 

// Helper function to get URL vars, courtesy of:

// http://papermashup.com/read-url-get-variables-withjavascript

 

function getUrlVars() {

  var vars = {};

  var parts =

    window.location.href.replace(

      /[?&]+([^=&]+)=([^&]*)/gi,

      function (m, key, value) {

        vars[key] = value;

      }

    );

  return vars;

}

 

// Selection constructor

 

function Selection(pts) {

 

  // Our (usually 4) corners

 

  this.points = pts;

 

  // Some constants

 

  this.csize = 6; // Resize cubes size

  this.csizeh = 10; // Grip cubes size (on hover)

  this.highlight = '#f00';

  this.linecolor = '#000';

  this.textcolor = '#fff';

 

  // Status of whether the mouse is hovering over or dragging the

  // corners

 

  this.hovering = [false, false, false, false]; // Hover status

  this.dragging = [false, false, false, false]; // Drag status

}

 

// Selection draw method

 

Selection.prototype.draw = function () {

 

  // Draw lines between the corners

 

  ctx.strokeStyle = this.linecolor;

  ctx.lineWidth = 2;

 

  ctx.beginPath();

  ctx.moveTo(this.points[0][0], this.points[0][1]);

  for (i = 1; i < this.points.length; i++) {

    ctx.lineTo(this.points[i][0], this.points[i][1]);

  }

  ctx.lineTo(this.points[0][0], this.points[0][1]);

  ctx.closePath();

  ctx.stroke();

 

  // Draw grip cubes

 

  for (i = 0; i < this.points.length; i++) {

    ctx.fillStyle =

      this.dragging[i] ? this.highlight : this.textcolor;

 

    // If a vertex is being dragged, draw it in a highlight colour

 

    if (this.dragging[i]) {

      var x = this.points[i][0];

      var y = this.points[i][1];

      var sz = this.csizeh;

 

      // First draw the grip cube

 

      ctx.strokeStyle = this.highlight;

      ctx.strokeRect(x - sz, y - sz, sz * 2, sz * 2);

      ctx.strokeStyle = this.linecolor;

 

      // Next we'll draw a magnified portion of the part of the

      // image under the cursor, with cross-hairs

 

      magctx.clearRect(0, 0, magsz, magsz);

 

      var xpos = x < off ? 0 : x - off;

      var ypos = y < off ? 0 : y - off;

      var fromRight = image.width - x;

      var fromBottom = image.height - y;

      var wid = fromRight <= off ? off + fromRight - 1 : off * 2;

      var hgt = fromBottom <= off ? off + fromBottom - 1 : off * 2;

      var fac = 1 / (off * 2);

      magctx.drawImage(

        image, xpos, ypos, wid, hgt,

        (xpos - (x - off)) * zs / 2, (ypos - (y - off)) * zs / 2,

        Math.floor(wid * fac * magsz),

        Math.floor(hgt * fac * magsz)

      );

 

      // And the cross-hairs

 

      magctx.strokeStyle = this.linecolor;

      magctx.lineWidth = 1;

 

      // Vertical

 

      magctx.beginPath();

      magctx.moveTo(magsz / 2, 0);

      magctx.lineTo(magsz / 2, magsz);

      magctx.closePath();

      magctx.stroke();

 

      // Horizontal

 

      magctx.beginPath();

      magctx.moveTo(0, magsz / 2);

      magctx.lineTo(magsz, magsz / 2);

      magctx.closePath();

      magctx.stroke();

    }

    else {

      // Draw filled cubes for the grips, larger if hovered over

 

      var csz = this.hovering[i] ? this.csizeh : this.csize;

      ctx.fillRect(

        this.points[i][0] - csz, this.points[i][1] - csz,

        csz * 2, csz * 2

      );

    }

 

    // Now we'll draw some text with the coordinate information in

    // them at an appropriate point for each of the corners

 

    // Make the text centered (vertically)

 

    ctx.textBaseline = 'middle';

    ctx.textAlign = 'left';

 

    // We'll work out X and Y offsets to make sure the text looks

    // OK and doesn't try to leave the canvas

 

    xoff = -2 * this.csizeh;

    yoff = (i > 0 && i < 3 ? 2 : -2) * this.csizeh;

 

    // If at the bottom, reverse the Y offset

 

    if (

      this.points[i][1] + yoff > ctx.canvas.height ||

      this.points[i][1] + yoff < 0

    )

      yoff = -yoff;

 

    // If at the left, make the text appear right at X = 0

 

    if (

      this.points[i][0] + xoff < 0

    )

      xoff = -this.points[i][0];

 

    // If nearly at the right, make the text right-justified and set

    // the location to be at the right margin

 

    if (

      this.points[i][0] + xoff > ctx.canvas.width - (4 * this.csizeh)

    ) {

      ctx.textAlign = 'right';

      xoff = ctx.canvas.width - this.points[i][0];

    }

 

    // And draw the coordinate text

 

    ctx.fillText(

      this.points[i][0] + ',' + this.points[i][1],

      this.points[i][0] + xoff,

      this.points[i][1] + yoff

    );

  }

}

 

function drawScene() { // Main drawScene function

 

  // Clear canvas

 

  ctx.clearRect(0, 0, image.width, image.height);

 

  // Draw source image

 

  ctx.drawImage(image, 0, 0, image.width, image.height);

 

  selArea.draw();

}

 

function onLoad() {

 

  // Just load our URL parameters once

 

  urlVars = getUrlVars();

 

  // Load the source image

 

  image = new Image();

  image.onload = function () {

 

    window.resizeTo(image.width, image.Height);

 

    // Create initial selection with points at 1/3 and 2/3

    // across the width and height

 

    var x1 = Math.floor(image.width / 3);

    var y1 = Math.floor(image.height / 3);

    var x2 = x1 * 2;

    var y2 = y1 * 2;

 

    selArea =

      new Selection([[x1, y1], [x1, y2], [x2, y2], [x2, y1]]);

 

    $('#panel').attr('width', image.width);

    $('#panel').attr('height', image.height);

    $('#magnifier').attr('width', magsz);

    $('#magnifier').attr('height', magsz);

 

    drawScene();

  }

 

  // Load the image specified in the URL parameter or a stock

  // image if not (useful for testing outisde AutoCAD)

 

  image.src =

    "input" in urlVars ?

      "file:///" + decodeURIComponent(urlVars["input"]) :

      "input.png";

 

  // Create canvas and context objects (also for magnified area)

 

  canvas = document.getElementById('panel');

  ctx = canvas.getContext('2d');

  magnifier = document.getElementById('magnifier');

  magctx = magnifier.getContext('2d');

 

  $('#panel').mousemove(function (e) { // Mouse-move event

 

    // Calculate the position of the mouse on the canvas

 

    var canvasOffset = $(canvas).offset();

    mouseX = Math.floor(e.pageX - canvasOffset.left - 2);

    mouseY = Math.floor(e.pageY - canvasOffset.top - 2);

 

    if (mouseX < 0) mouseX = 0;

    if (mouseY < 0) mouseY = 0;

    if (mouseX >= image.width) mouseX = image.width - 1;

    if (mouseY >= image.height) mouseY = image.height - 1;

 

    // Hovering over resize cubes

 

    for (i = 0; i < selArea.points.length; i++) {

      var x = selArea.points[i][0];

      var y = selArea.points[i][1];

      var csz = selArea.csizeh;

      selArea.hovering[i] =

        mouseX > x - csz && mouseX < x + csz &&

        mouseY > y - csz && mouseY < y + csz;

    }

 

    // In case of dragging of resize cubes

 

    for (i = 0; i < selArea.points.length; i++) {

      if (selArea.dragging[i]) {

        selArea.points[i][0] = mouseX;

        selArea.points[i][1] = mouseY;

      }

    }

 

    drawScene();

  });

 

  $('#panel').mousedown(function (e) { // Mouse-down event

 

    // Stop the standard behaviour from replacing our cursor

    // with an I-bar

 

    e.preventDefault();

 

    // If already hovering over a particular corner, set its

    // drag status to true

 

    for (i = 0; i < selArea.points.length; i++) {

      if (selArea.hovering[i]) {

        selArea.dragging[i] = true;

      }

    }

  });

 

  $('#panel').mouseup(function (e) { // Mouse-up event

 

    // Set all the drag statuses to false

 

    for (i = 0; i < selArea.points.length; i++) {

      selArea.dragging[i] = false;

    }

 

    // Clear the magnified view

 

    magctx.clearRect(0, 0, magsz, magsz);

  });

 

  drawScene();

}

 

function deskewCmd() {

 

  // Get the name of the output file chosen by the user from

  // the URL parameters and replace its backslashes with forward

  // slashes (or we just use a standard name, if not found)

 

  var output =

    "output" in urlVars ?

      "file:///" +

      decodeURIComponent(urlVars["output"]).replace(/\\/g,"/") :

      "output.png";

 

  // Execute our command to process the image with the provided

  // coordinates and width factor

 

  var pts = selArea.points;

  Acad.Editor.executeCommandAsync(

    '_.DESKEW_IMAGE ' + '\"' + image.src + '\" \"' + output + '\" ' +

    pts[0][0].toString() + ',' + pts[0][1].toString() + ',' +

    pts[1][0].toString() + ',' + pts[1][1].toString() + ',' +

    pts[2][0].toString() + ',' + pts[2][1].toString() + ',' +

    pts[3][0].toString() + ',' + pts[3][1].toString() + ' ' +

    $('#factor').val().toString()

  );

 

  // Close this dialog (setting the commit flag to true)

 

  Acad.Application.activedocument.modalDialogCommit(true);

}

Now let's look at the additional C# implementation we use to display the HTML page. What's especially interesting about this is the way we tell the page what to load: we're encoding the arguments we want to pass (which in this case means the input filename as well as the output filename – more on why we do that in a bit) as URL parameters, which means they come after the URL to the .htm page in this way:

http://my-site.something/my-page.htm?input=input-url&output=output-url

Please don't click on this link – it won't take you anywhere interesting. My apologies to those of you who clicked on it before reading this message. 😉

The input-url and output-url parameters will be URL-encoded URLs (which would typically reside on the local file-system, but not necessarily, I suppose) to the input and output files. To say they're URL-encoded means that they have any characters that might conflict with the outer URL (such as colons, forward-slashes, etc.) replaced with % and a hexadecimal number representing their ASCII value. An example as when you come across a space in an URL that gets replaced by %20. To encode filesystem URLs using .NET we had a minor issue to deal with, but the provided workaround was straightforward enough to include in our code.

So why also pass the output location? We could have maintained that value in the C# code, of course, setting it as a member variable on the Commands class that gets picked up later, but that makes for a more tightly-coupled system. We ideally want our DESKEW_IMAGE command to be a clean interface to our Python functionality so that it can be called separately. It does mean the values need to be URL-encoded, but that's a fairly trivial task from most modern programming languages (and we could also adjust the code to pass these in a less web-centric – perhaps more LISP-friendly – way, if we so chose).

Here's the additional C# code:

[CommandMethod("DESKEW")]

public void DeskewRasterImageCommand()

{

  var doc =

    Application.DocumentManager.MdiActiveDocument;

  var ed = doc.Editor;

 

  var asm = Assembly.GetExecutingAssembly();

  var expath = Path.GetDirectoryName(asm.Location) + "\\";

  var uipath = expath + "HTML\\";

 

  var pofo =

    new PromptOpenFileOptions("Select image file to de-skew");

  pofo.Filter =

    "Portable Network Graphics (*.png)|*.png";

 

  var cwd = Directory.GetCurrentDirectory();

 

  var pr = ed.GetFileNameForOpen(pofo);

  if (pr.Status != PromptStatus.OK)

    return;

 

  var filename = pr.StringResult;

 

  var pofs =

    new PromptSaveFileOptions(

      "Specify file to save de-skewed image to"

    );

  pofs.Filter =

    "Portable Network Graphics (*.png)|*.png";

 

  pr = ed.GetFileNameForSave(pofs);

  if (pr.Status != PromptStatus.OK)

    return;

 

  var deskewed = pr.StringResult;

 

  // Workaround: GetFileNameForOpen() seems to change the

  // working directory to that of the selected file

 

  Directory.SetCurrentDirectory(cwd);

 

  var resized = Path.GetTempFileName();

  bool doresize = false;

  var sz = ImageSize(filename);

 

  if (

    (sz.Width > 600 && sz.Height > 600) ||

    sz.Width > 1000 || sz.Height > 1000

  )

  {

    ed.WriteMessage(

      "\nImage bounds are {0} x {1}. ", sz.Width, sz.Height

    );

    int newHeight, newWidth;

    if (sz.Width > sz.Height)

    {

      newHeight = 600;

      newWidth = sz.Width * newHeight / sz.Height;

    }

    else

    {

      newWidth = 600;

      newHeight = sz.Height * newWidth / sz.Width;

    }

 

    doresize =

      !GetYesOrNo(

        ed,

        "Resize to " +

        newWidth.ToString() + " x " + newHeight.ToString() + "?",

        true

      );

    if (doresize)

    {

      ResizeImage(

        filename, resized, newWidth, newHeight

      );

    }

  }

 

  if (!doresize)

  {

    File.Copy(filename, resized, true);

  }

 

  // Workaround for Microsoft bug 594562:

  // http://connect.microsoft.com/VisualStudio/feedback/details/594562/uri-class-does-not-parse-filesystem-url-with-query-string

  // http://stackoverflow.com/questions/8757585/why-doesnt-system-uri-recognize-query-parameter-for-local-file-path

 

  var uriParserType = typeof(UriParser);

  var fileParserInfo =

    uriParserType.GetField(

      "FileUri", BindingFlags.Static | BindingFlags.NonPublic

    );

  var fileParser =

    (UriParser)fileParserInfo.GetValue(null);

  var fileFlagsInfo =

    uriParserType.GetField(

      "m_Flags", BindingFlags.NonPublic | BindingFlags.Instance

    );

  int fileFlags = (int)fileFlagsInfo.GetValue(fileParser);

  int mayHaveQuery = 0x20;

  fileFlags |= mayHaveQuery;

  fileFlagsInfo.SetValue(fileParser, fileFlags);

 

  var fileUri =

    new System.Uri(

      "file://" + uipath +

      "ImageView.htm?input=" +

      HttpUtility.UrlEncode(

        doresize ? resized : filename

      ) +

      "&output=" + HttpUtility.UrlEncode(deskewed)

    );

 

  Application.ShowModalWindow(fileUri);

}

 

private Size ImageSize(string imageFile)

{

  using (var src = System.Drawing.Image.FromFile(imageFile))

  {

    return new Size(src.Width, src.Height);

  }

}

 

// Based on

// http://stackoverflow.com/questions/11137979/image-resizing-using-c-sharp

 

private void ResizeImage(

  string imageFile, string outputFile,

  int newWidth, int newHeight

)

{

  using (var src = System.Drawing.Image.FromFile(imageFile))

  {

    using (var newImage = new Bitmap(newWidth, newHeight))

    using (var graphics = Graphics.FromImage(newImage))

    {

      graphics.SmoothingMode = SmoothingMode.AntiAlias;

      graphics.InterpolationMode =

        InterpolationMode.HighQualityBicubic;

      graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;

      graphics.DrawImage(

        src, new Rectangle(0, 0, newWidth, newHeight)

      );

      newImage.Save(outputFile);

    }

  }

}

 

private static bool GetYesOrNo(

  Editor ed,

  string prompt,

  bool defval

)

{

  bool changed = false;

  var pko =

    new PromptKeywordOptions(prompt + " [Yes/No]: ", "Yes No");

 

  // The default depends on our current settings

 

  pko.Keywords.Default = (defval ? "Yes" : "No");

  var pr = ed.GetKeywords(pko);

 

  if (pr.Status == PromptStatus.OK)

  {

    // Change the settings, as needed

 

    bool newval =

      (pr.StringResult == "Yes");

 

    if (defval != newval)

    {

      changed = true;

    }

  }

  return changed;

}

Here are a few boring demonstration videos of the DESKEW command in action. I say they're boring but the first minute and the last 5 seconds of each are actually quite interesting, and the bits in between I've left uncut to give people a sense of how long the code currently takes to work. Please feel free to fast forward those parts, although it is kinda cool to see AutoCAD's progress meter being controlled from Python code.

Here's the de-skewing of the painting in my living room:

And here's the de-skewing of the original whiteboard image provided as part of the linear algebra class that inspired this project:

That's about it for the main body of this series. That said – and as I've mentioned before – I do want to investigate the possibility of moving the Python core into the cloud, most probably using Google App Engine. We could then decouple the application even further from AutoCAD, by calling the web-service directly from the HTML page and only then launching (for instance) the IMAGEATTACH command inside AutoCAD with the output image location.

I'd also like to see whether Google's famed MapReduce mechanism can usefully be thrown at parts of the problem (the most likely candidate area being the code that currently generates the output pixels from the transformed image information). I expect the overhead would only make sense for larger images, but we'll see.

2 responses to “Selecting an area of a perspective image to de-skew inside AutoCAD using HTML5 and JavaScript”

  1. Robert Vaughan Avatar
    Robert Vaughan

    Hi Kean,

    How do I implement or install the deskew code into Autocad?
    Im looking at using the deskew command to "deskew" digital images of steel plate for plasma profile cutting. The idea is to create dxf's of the offcuts to import into nesting software so that dxf profiles can be added to the existing offcuts.

    1. Kean Walmsley Avatar

      Hi Robert,

      There's actually a fair amount to it, as there are lots of moving pieces (all of which are provided, but some need to be built, others may need to be updated...). And the performance - with the performance being in Python - is rather slow: you may be better of researching 3rd party apps (perhaps in Exchange) that can do this for you.

      Regards,

      Kean

      1. Robert Vaughan Avatar
        Robert Vaughan

        Thank you for the quick reply i will check out the exchange.

      2. Robert Vaughan Avatar

        Update, Found this tool by Sean Tesser - STSC_Projective2D. apps.exchange.autod...

        Regards,

        Robert.

  2. Sarah Cartwright Avatar
    Sarah Cartwright

    Hi! There's a bunch of files missing in the Visual Studio project, such as the Resources folder. =(

    1. Kean Walmsley Avatar
      Kean Walmsley

      Well it's 12 years later and I'm really not in a position to advise you on this. The ZIP looks reasonably complete - if there are any files missing then they're very likely just boilerplate. Try copying them from a new project.

      Kean

Leave a Reply to Robert Vaughan Cancel reply

Your email address will not be published. Required fields are marked *