Using Konva.js to annotate image with bounding boxes

References:

First up, a working demo below. Click on “Set image” button first. Click “Add box” button to create a new bounding box. Each time the box is created/moved/resized, a CustomEvent is emitted and output in the console as well as in the demo. The position and size of each box is always restricted within the image. The boxes are scaled proportionally when the browser window is resized. Finally, try saving the image in its original dimensions to verify that the bounding boxes are in their correct positions.

 

Source code as follows (see the last link in References above if the saved image cannot be opened, most likely due to CORS):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      button.demo {
        background: #eee;
        border: 1px solid black;
        border-radius: 4px;
        color: black;
        display: inline;
        font-size: 0.8em;
        font-weight: normal;
        margin: 0.5em;
        padding: 0.25em 0.5em;
      }
      button.demo:focus {
        background: #eee;
      }

      #stage-container {
        height: 400px;
        width: 95%;
      }

      #stage {
        max-height: 100%;
        max-width: 100%;
      }
      #stage .konvajs-content {
        outline: 1px solid black;
      }

      #notes {
        font-family: monospace;
        width: 95%;
        word-wrap: break-word;
      }
    </style>
  </head>

  <body>
    <button class="demo"
      onclick="demoScript.setImage('park.jpg', { id: `img-${Date.now()}` });">
      Set image
    </button>
    <button class="demo"
      onclick="demoScript.addBox('#ff0000', '#0000ff40', { id: `addbox-${Date.now()}` });">
      Add box
    </button>
    <button class="demo" onclick="demoScript.saveOriginalImageWithBoxes();">
      Save image in original dimensions with bounding boxes
    </button>
    <br><br>

    <div id="notes"></div><br>
    <div id="stage-container">
      <!--
        Stage is initially empty with no width/height hence wrapped in
        a container with fixed width/height.
      -->
      <div id="stage"></div>
    </div>

    <script src="https://unpkg.com/konva@8/konva.min.js"></script>
    <script>
        let stageElement = document.querySelector('#stage');
        stageElement.addEventListener('demo.box.update', (event) => {
            console.log(event.detail);
            document.getElementById('notes').innerHTML =
                event.detail.action + ': '
                + JSON.stringify(event.detail.boundingBox);
        });

        /**
         * References:
         *   - https://konvajs.org/docs/index.html
         *   - https://konvajs.org/api/Konva.html
         *   - https://konvajs.org/docs/sandbox/Image_Labeling.html
         *   - https://konvajs.org/docs/data_and_serialization/High-Quality-Export.html
         *   - https://konvajs.org/docs/posts/Tainted_Canvas.html
         */
        const demoScript = (function (stageElement) {
            /**
             * Self reference - all public properties/methods are stored here
             * and returned as public interface.
             *
             * @public
             * @type {object}
             */
            const self = {};

            // This Konva stage has a background layer that contains the image,
            // and each time a new box is added, a new layer is created to
            // contain the box and a transformer.
            let stage = null;
            let backgroundLayer = null;
            let imageShape = null; // not named "image" to prevent confusion with <img>
            let imgElement = null; // the actual <img> element, which is added to imageShape
            let currStageWidth = 0;
            let currStageHeight = 0;

            // Add event listener on resize
            window.addEventListener('resize', resizeStage);

            // Initialize only when DOM tree is ready
            window.addEventListener('DOMContentLoaded', function (event) {
                // Init stage
                [currStageWidth, currStageHeight] = getStageWidthHeight();
                stage = new Konva.Stage({
                    id: 'stage', // unique ID for Konva, find using `stage.find('#stage')`
                    container: stageElement,
                    width: currStageWidth,
                    height: currStageHeight,
                });

                // Init background layer - can find using e.g. `stage.find('#background')`
                backgroundLayer = new Konva.Layer({ id: 'background' }); // unique ID for Konva

                // Create <img> element and Konva.Image
                imgElement = new Image(); // this can be accessed later via `image.image()`
                imgElement.onload = function (event) {
                    imageShape.clearCache(); // must run this each time changing img src
                    resizeStage(); // this will add imgElement to image the first time
                };
                imageShape = new Konva.Image({
                    id: 'image', // unique ID for Konva, find using `stage.find('#image')`
                    x: 0,
                    y: 0,
                    image: imgElement,
                });
                backgroundLayer.add(imageShape);

                // Add layer to stage
                stage.add(backgroundLayer);
            });

            /**
             * Set image for stage
             *
             * @public
             * @param {string} imgSrc - Path to image to put in `<img src="" />`.
             * @param {object} dataAttributes - Optional key-value pairs to put as data attributes
             *     for image. { id:1 } would be similar to yielding `<img data-id="1" />`.
             *     The actual attributes will be stored on the Konva.Image object not the
             *     HTMLImageElement.
             * @returns {void}
             */
            self.setImage = function (imgSrc, dataAttributes = null) {
                // Can set custom attrs on Konva object as long as no conflict
                Object.keys(dataAttributes || {}).forEach((key) => {
                    imageShape.setAttr(`data-${key}`, dataAttributes[key]);
                });

                imageShape.image().src = imgSrc; // load image src
            };

            /**
             * Save image in original dimensions with bounding boxes
             *
             * This will trigger a download and will help verify that the
             * boundingBox returned by emitEvent() is computed correctly.
             *
             * @returns {void}
             */
            self.saveOriginalImageWithBoxes = function () {
                let imgElement = imageShape.image();
                let imgNaturalWidth = parseFloat(imgElement.naturalWidth);
                let dataUrl = stage.toDataURL({
                    pixelRatio: imgNaturalWidth / stage.width(),
                });

                let link = document.createElement('a');
                link.download = `stage-${Date.now()}.png`;
                link.href = dataUrl;
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
                delete link;
            };

            /**
             * Add new box
             *
             * A new layer needs to be created for each box else the transformer will group all
             * boxes in the layer, hampering moving/transforming of individual boxes.
             *
             * @public
             * @param {string} foregroundColor - Color code to use for border color of box,
             *     e.g. #ff0000. Use #rrggbbaa format to specify opacity, e.g. #ff000040.
             * @param {string} backgroundColor - RGB color code to use for fill color of box,
             *     e.g. #0000ff. Use #rrggbbaa format to specify opacity, e.g. #0000ff40.
             * @param {object} dataAttributes - Optional key-value pairs to put as data attributes
             *     for image. { id:1 } would be similar to yielding `<div data-id="1">`.
             *     The actual attributes will be stored on the Konva.Rect object.
             * @returns {void}
             */
            self.addBox = function (foregroundColor, backgroundColor, dataAttributes = null) {
                let timestamp = Date.now();
                let boxLayer = new Konva.Layer({ id: `box-layer-${timestamp}` }); // unique ID for Konva

                let boxShape = new Konva.Rect({
                    id: `box-${timestamp}`, // unique ID, find using `stage.find('#box-123')`
                    name: 'box', // non-unique name, find using `stage.find('.box')`
                    x: 20,
                    y: 20,
                    width: 100,
                    height: 50,
                    fill: backgroundColor,
                    stroke: foregroundColor,
                    strokeWidth: 1,
                });

                // Can set custom attrs on Konva object as long as no conflict
                boxShape.draggable('true');
                Object.keys(dataAttributes || {}).forEach((key) => {
                    boxShape.setAttr(`data-${key}`, dataAttributes[key]);
                });

                // Restrict moving of box within stage
                boxShape.on('dragend', (event) => {
                    let x = boxShape.x();
                    let y = boxShape.y();
                    let width = boxShape.scaleX() * boxShape.width();
                    let height = boxShape.scaleY() * boxShape.height();
                    let stageWidth = stage.width();
                    let stageHeight = stage.height();

                    if (x < 0) {
                        boxShape.x(0);
                    }
                    if (y < 0) {
                        boxShape.y(0);
                    }

                    if (x + width > stageWidth) {
                        boxShape.x(stageWidth - width);
                    }
                    if (y + height > stageHeight) {
                        boxShape.y(stageHeight - height);
                    }

                    emitEvent('drag', boxShape, event);
                });

                // Restrict resizing of box within stage - note that transformation
                // is stored in scaleX/scaleY not in width/height and that scaleX/scaleY
                // only applies to dimensions not position
                boxShape.on('transformend', (event) => {
                    let width = boxShape.width();
                    let height = boxShape.height();
                    let stageWidth = stage.width();
                    let stageHeight = stage.height();

                    // Revert to original scale if exceed. Not scaling to stage
                    // dimensions cos box may overflow stage if its x or y more than 0.
                    if ((boxShape.scaleX() * width > stageWidth)
                        || (boxShape.scaleY() * height > stageHeight)
                    ) {
                        boxShape.scaleX(1);
                        boxShape.scaleY(1);
                    } else {
                        emitEvent('transform', boxShape, event);
                    }
                });

                // Create transformer to allow transforming of box
                // Note that boundBoxFunc applies to box containing all nodes not individual ones
                let transformer = new Konva.Transformer({
                    id: `box-transfomer-${timestamp}`, // unique ID for Konva
                    anchorSize: 5,
                    flipEnabled: false,
                    keepRatio: false,
                    rotateEnabled: false,
                });
                transformer.nodes([boxShape]);

                // Add objects to layer and layer to stage
                boxLayer.add(boxShape);
                boxLayer.add(transformer);
                stage.add(boxLayer);

                emitEvent('add', boxShape, null);
            }

            /**
             * Emit event
             *
             * This will calculate the bounding box as per the original dimensions of the image.
             *
             * @private
             * @param {string} action
             * @param {Konva.Shape} boxShape
             * @param {(null|Event)} sourceEvent
             * @returns {void}
             */
            function emitEvent(action, boxShape, sourceEvent) {
                let imgElement = imageShape.image();
                let imgNaturalWidth = parseFloat(imgElement.naturalWidth);
                let imgNaturalHeight = parseFloat(imgElement.naturalHeight);
                if (!imgNaturalWidth) { // prevent division by zero when calc aspect ratio
                    return;
                }
                let imgAspectRatio = 1.0 * imgNaturalWidth / imgNaturalHeight;

                // Compute actual bounding box as per original dimensions of image
                // Note that boxShape.scaleX/scaleY only applies to dimensions not pos
                // Format will be x1,y1,x2,y2
                let naturalScaleX = imgNaturalWidth / imageShape.width(); // cannot use stage dimensions cos inaccurate
                let naturalScaleY = imgNaturalHeight / imageShape.height();
                let x1 = naturalScaleX * boxShape.x();
                let y1 = naturalScaleY * boxShape.y();
                let x2 = x1 + (naturalScaleX * boxShape.scaleX() * boxShape.width()); 
                let y2 = y1 + (naturalScaleY * boxShape.scaleY() * boxShape.height());

                stage.container().dispatchEvent(new CustomEvent('demo.box.update', {
                    bubbles: false,
                    detail: {
                        action: action,
                        boundingBox: {
                            originalImageWidth: imgNaturalWidth,
                            originalImageHeight: imgNaturalHeight,
                            x1: x1,
                            y1: y1,
                            x2: x2,
                            y2: y2,
                        },
                        box: boxShape.getAttrs(),
                        image: imageShape.getAttrs(),
                    },
                }));
            }

            /**
             * Get stage dimensions
             *
             * @private
             * @returns {array} Format: [<width>, <height>].
             */
            function getStageWidthHeight() {
                // Have to use computed style of parent of stage cos its dimensions are fixed and
                // contains the stage while stage itself keeps resizing to fit contents and is
                // initially empty
                let stageNode = stage ? stage.container() : stageElement;
                let style = window.getComputedStyle(stageNode.parentNode);
                return [parseFloat(style.width), parseFloat(style.height)];
            }

            /**
             * Handler used to resize stage when window is resized
             *
             * @private
             * @returns {void}
             */
            function resizeStage() {
                let [stageWidth, stageHeight] = getStageWidthHeight();
                let imgElement = imageShape.image();
                let imgNaturalWidth = parseFloat(imgElement.naturalWidth);
                let imgNaturalHeight = parseFloat(imgElement.naturalHeight);
                let imgAspectRatio = 1.0 * imgNaturalWidth / imgNaturalHeight;

                // Keep img within stage especially if tall image
                let width = 0;
                let height = 0;
                if (imgAspectRatio >= 1) { // wide/square image
                    width = stageWidth;
                    height = width / imgAspectRatio;
                } else { // tall image
                    height = stageHeight,
                    width = imgAspectRatio * height;
                }
                if (width > stageWidth) { // double-check
                    width = stageWidth;
                    height = width / imgAspectRatio;
                } else if (height > stageHeight) {
                    height = stageHeight,
                    width = imgAspectRatio * height;
                }

                // Resize existing image - no need to scale cos x and y are always 0
                imageShape.width(width);
                imageShape.height(height);

                // Resize all boxes and scale their position and dimensions
                let scale = width / currStageWidth; // this is not related to scaleX or scaleY of box
                stage.getLayers().forEach((layer) => { // note each box has its own layer
                    if ('background' === layer.getAttr('id')) {
                        return;
                    }

                    let boxShapes = layer.getChildren((node) => {
                        return ('box' === node.getAttr('name'));
                    });
                    boxShapes.forEach((boxShape) => {
                        boxShape.width(scale * boxShape.width());
                        boxShape.height(scale * boxShape.height());
                        boxShape.x(scale * boxShape.x());
                        boxShape.y(scale * boxShape.y());
                    });
                });

                // Make stage fit contents
                stage.width(width);
                stage.height(height);
                currStageWidth = width;
                currStageHeight = height;
            }

            // Return public interface of IIFE
            return self;
        })(stageElement);
    </script>
  </body>
</html>