References:
- Konva Tutorials
- Konva API Docs
- Drawing Labels on Image with canvas
- HTML5 Canvas Export to High Quality Image Tutorial
- Resolving “Tainted canvases may not be exported” with Konva
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>